ADR-016. A clearable field is cleared with the empty value of its type, and an explicit null is refused

Status: accepted, 8 September 2026. Still in force. Supersedes ADR-012.

Context

The contract marks a writable field as clearable with oneOf: [T, null] or type: [T, "null"]. Ansible does not tell an omitted option from an explicit null: module.params carries None in both cases, so description: null and description absent produced the same request, and the module reported ok on a description that was still there.

ADR-012 closed the false success by refusing the null, and was right to. It bought the refusal with a marker default on a raw option, and validate-modules requires whatever the module declares to be published: the page then showed type: raw and default: __unchanged__ on every clearable option, and the description had to restore the type the type field no longer carried. That record also left open what clearing means, and its refusal could not tell the reader what to write instead.

Two things were measured before deciding.

How to tell an omitted option from an explicit null, at no cost on the page. ADR-012 had ruled out fallback on this line:

type: str or raw, marker fallback: distinguishable, everywhere, but mutually_exclusive then fails on every call.

The measurement was right and its scope was too narrow. It measured a fallback that returns a value. Ansible applies a fallback before counting present keys, so a returned value makes the key present, and mutual exclusion fires on every call. A fallback that raises AnsibleFallbackNotFound injects nothing, and Ansible calls it only when the key is absent from the invocation. That is a witness, not a value, and it was never measured. On 2.17.14, 2.18.19, 2.19.12, 2.20.8 and 2.21.3, across str, int, bool, list and dict:

what the invocation does

the witness fires

module.params

omits the key

yes

None

writes key: null

no

None

writes a value

no

the converted value

mutually_exclusive accepts a single provided option and still refuses two, on all five versions. The option keeps its type, its choices and its elements, and nothing about the mechanism reaches the page.

What clearing actually is. This was measured on the real account on 8 September 2026, on a security group created for the measurement and deleted after, with the residue check green on both sides:

body sent

status

reread

{"description": null}

200

'une description à effacer', unchanged

{"description": ""}

200

'', cleared

{"tags": null}

200

['un', 'deux'], unchanged

{"tags": []}

200

[], cleared

{"stateful": null}

400

True

{"stateful": false}

200

False

The API accepts null on a field the contract calls clearable, answers 200, and changes nothing. The last two rows confirm it from the other end: stateful is not declared clearable, and there null is rejected outright.

That is coherent, and the explanation is upstream of Scaleway. The nullability the parser reads sits behind a $ref to a protobuf wrapper type (google.protobuf.StringValue and its family), whose whole purpose is to say “field not set” over the wire. Declaring a field clearable means the decoder accepts null, and null means I am not talking about this field. It is the wire-level twin of an omitted key, not a clearing instruction.

A first implementation of this record sent the null and let the postcondition check of ADR-010 judge the reread. It worked exactly as designed and failed the module, both against the emulator and against the real account, with a message about normalisation where there was none. The measurement above is what replaced it.

Decision

Clearing a field is writing the empty value of its type. description: "", tags: [], config: {}. This needs no mechanism at all: an empty value goes down the ordinary path, is compared by the ordinary strategy, and is checked by the ordinary postcondition. A second run reports no change.

An explicit null is refused, before anything is read, and the refusal names what to write. The message states the empty value of that option’s type, literally, so it can be copied. For a type with no empty value, int and bool, it says so instead of inventing one: 0 and false are values a playbook can already write, not absences.

The refusal needs the omission witness, and that is the whole reason it exists. Without it, refusing every None would refuse every playbook that does not write all of its options.

A clearable option is published exactly like any other option. Its type, choices and elements are those of the contract; it carries no default and no marker. Its description says how to clear it, and that null is refused, because the criterion of this repository is that a module is understood from its page alone.

A contract default on a clearable field is refused at generation. With a default, Ansible converts an explicit null toward the option type and description: null reaches the module as '', which would make a refused null indistinguishable from a legitimate clearing. No field is in that case; the refusal waits for the one that will be.

A module that declares clearable fields without a witness fails. Passing no witness is not the same as passing an empty one: the first says nothing was posted, the second says nothing was omitted. Confusing them would refuse every omitted option, so the runtime refuses to guess.

Consequences

type: raw and the marker default leave the collection, and with them the runtime code that stripped the marker and revalidated the provided value against a second copy of the option entry. The type is described once, on the published entry, and the witness reads it there to build its message.

Clearing works today for str, list and dict. It does not work for int and bool, and no module can make it work: the API offers no representation of an empty integer. The page says so on each of those options rather than leave the reader to discover it. Some of them carry a documented sentinel in their own contract description, Use 0 value to disable for instance, and that sentence is published as it stands: it belongs to the field, not to this rule.

The mechanism is a public API used for a purpose it was not designed for. A fallback is meant to supply a value; this one supplies none and records a name. Nothing in Ansible promises that a fallback is called only on an absent key, so the property is measured by a test that interrogates ArgumentSpecValidator rather than asserted in prose, and that test turns red on the version where the property stops holding.

The witness fills during AnsibleModule.__init__ and is read after it. A module process handles one invocation, so there is one witness per process; the tests that build several modules in one process clear it explicitly.

An explicit null on a field the contract does not declare clearable is still dropped in silence by the module, and the API rejects it in 400 if it ever reaches it. A null on an action module’s body field is also still dropped: clearing has no meaning on an action body, whose fields describe one call rather than a persistent resource.

What this record does not decide

Whether the empty value clears on every endpoint, or only on the ones measured here. UpdateSecurityGroup was measured on str and on list. A field where the API normalises differently will surface through the postcondition check, which names it, and will be handled by a comparison strategy chosen under ADR-009 and written as an override with its reason.