Use Annotation-Driven Nullable Form Fields Instead of Per-Config enabled Toggles
Context
The Form Duality system in packages/core/swiss_ai_hub/core/form/ lets a single Pydantic class double as a form schema (when fields hold FormkitElement instances) and a typed data model (when fields hold primitives). Several sub-configs that wrap an optional capability — RetrievePrevNextConfig, RetrieveSummariesConfig, RerankingConfig — emerged with a recurring pattern:
class RerankingConfig(Form):
enabled: Annotated[bool | Checkbox, Field(...)] = False
reranking_model: Annotated[RerankingModelConfig | None, Field(...)] = NoneThe enabled field exists purely to drive a UI toggle. Each dependent field carries condition_if="$get(reranking_config_enabled).value" to hide itself when off, runtime code reads config.reranking_config.enabled, and when toggled off the form still submits a payload like {enabled: false, reranking_model: {top_n: 5, ...}} — dead values that travel over the wire and pollute the runtime config. The pattern duplicated semantics that Pydantic's T | None annotation already encodes ("this whole thing is optional"), forced every new optional sub-form to repeat the pattern, and leaked UI concerns into the persisted data model.
The first attempt on feat/toggle-retrieval-postprocessors shipped this enabled-checkbox pattern on the two retrieval post-processor configs. Reviewing the diff surfaced the duplication and motivated lifting the toggle into the form framework itself.
Decision Drivers
- Single source of truth for optionality
T | Nonealready encodes "this is optional" in the type system. A separateenabled: boolfield encodes the same fact a second time, and the two can drift (e.g.enabled=true, reranking_model=Noneis a valid Pydantic state but meaningless at runtime — guarded only by ad-hocmodel_validators). - Clean wire payload
When the user disables a sub-form, the framework should submitfield: nullrather thanfield: {enabled: false, ...stale defaults}. This keeps Mongo documents small, makes diffs in the agent config trivial to read, and removes the need for runtime code to interpret anenabledflag alongside the value. - Eliminate per-config boilerplate
Every nullable sub-form previously needed anenabledfield, anas_form()Checkbox with a uniqueref, acondition_ifon every dependent element, and i18n strings for the toggle label. The framework approach removes all of this from user code. - No new abstraction in the data model
The decision deliberately keeps the toggle out of the persisted Pydantic model. Devs who write aForm | Nonefield get the toggle for free; the data model stays a clean record of what is or isn't configured. - Symmetric handling of nullable groups and nullable scalars
A singlenullable: boolflag onFormkitElementlets the same UI affordance work for bothSubForm | Noneandint | InputNumber | None, instead of forcing two parallel patterns. - Pre-existing infrastructure already round-trips null
Form._convert_union_for_submissionalready preservestype(None),Form.deep_mergealready overwrites withnull, and the frontendcleanFormDataalready passesnullthrough. The missing piece was only the toggle UI and the submit-time coercion — a small framework addition rather than a large refactor.
Decision
We treat any Form | None or T | None annotation in a Form subclass as the source of truth for the toggle, and remove every hand-rolled enabled field that exists solely to drive that UI affordance.
Backend (packages/core/swiss_ai_hub/core/form/):
FormkitElementgains anullable: bool = Falsefield on the base class.Form.to_formkit_form()auto-stampsnullable=Trueon emitted elements when the field annotation allowsNone, skipping boolean inputs (primeCheckbox,primeToggleSwitch) where a tri-state toggle would be meaningless.- For a
Form | Nonefield whose form-mode value isNone, the emitter instantiates a placeholderGroupfrom the nestedForm.as_form()template so the user can re-enable a previously-null sub-form.
Frontend (packages/web/swiss_ai_hub_web/composables/form/):
- When
useFormKitTransformencounterselement.nullable === true, it emits a synthetic__<field>__enabledCheckbox sibling and gates the original element withif: $__<field>__enabled(combined with any author-suppliedcondition_if). seedNullableToggles()initialises the synthetic flag from initial data (<field> !== null && !== undefined).coerceNullableToggles()runs at submit time: when the synthetic toggle is off, it sets the field tonulland strips the synthetic key. Both helpers recurse into nested groups and repeater items.
Migration on the same branch: drop the enabled field from RetrievePrevNextConfig, RetrieveSummariesConfig, and RerankingConfig; revert runtime guards in KnowledgeRetriever, RetrievalAgent, RAGAgent, and ExpertRAGAgent to plain truthy / is not None checks; delete the now-unused enabled.label / enabled.help keys from the de/en/fr/it translation files.
Plain-bool toggles that carry independent semantic meaning (enable_organization_memory, enable_user_memory_*, LLMConfig.logprobs) are not in scope — they are real domain flags forwarded to runtime logic or downstream APIs, not wrappers around an optional sub-form.
Consequences
Positive
- Removes ~45 lines of
enabledboilerplate from three retrieval/reranking configs and unifies them under the framework feature. - Persisted agent configs are cleaner: a disabled retrieval post-processor stores as
retrieve_prev_next: nullinstead of{enabled: false, num_nodes: 10, mode: "both"}. - Future optional sub-forms get the toggle for free — developers only declare
field: SubConfig | None = None. - Runtime code uses idiomatic Python (
if config.retrieve_prev_next:) rather than reading a bespoke.enabledflag. - The synthetic toggle key (
__<field>__enabled) lives only in the frontend transform layer, not in the data model or on the wire.
Trade-offs
- The placeholder Group emitted for a
None-valued sub-form requires the nestedFormsubclass to implementas_form(). Sub-forms withoutas_form()cannot be re-enabled by the user once null — they would need either a default form-mode template or a code change. - Nullable scalar fields whose form-mode value is also
Noneare out of scope: the framework needs some element instance to attach the toggle to, so devs must still assign aFormkitElementinas_form()even when the data-mode default isNone. - Nullable top-level groups extracted by
extractGroupConfigs(rendered as their own stepper step) do not receive the toggle — only nested-group and leaf cases are handled. Real-world configs are nested, so this is acceptable; if needed, a follow-up can extend stepper rendering. - The frontend now carries a small bespoke
__<field>__enabledconvention. It is encapsulated inuseFormKitTransform.tsanduseCreateInstanceForm.ts, but anyone reading rawformDatamid-edit will see the synthetic keys.
