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: strorraw, markerfallback: distinguishable, everywhere, butmutually_exclusivethen 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 |
|
|---|---|---|
omits the key |
yes |
|
writes |
no |
|
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 |
|---|---|---|
|
200 |
|
|
200 |
|
|
200 |
|
|
200 |
|
|
400 |
|
|
200 |
|
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.