Ansible Dysfunction #2: included files skipped with 'when:' still modify content

This is me venting about Ansible. It does fantastic things (building multiple entire, fully equipped servers in minutes), but it does it so awkwardly ... (Ansible 2.3.x - confirmed on Fedora Core and Mac.)

Ansible includes a when: statement. You might foolishly assume, coming from almost any other programming language, that a statement that accepts a logical construction to decide whether or not to do something is essentially an if-then-else. You're in for a spectacular surprise. The first thing you need to understand is that Ansible doesn't precisely "skip" the role: it runs through all the YAML in the skipped role, and parses and prints 'skipped' for every single command. And - as you'll see - it sometimes does a bit more than that.

It took me a while to sort this one out, because I thought that "skipped" roles (ones that weren't run because the when: statement processed as false) were actually modifying any variable named in the skipped role. This is incorrect: variables that would be modified by a set_fact: in a skipped role will remain untouched if the role is "skipped." The problem is variables acquired by the register: statement. These are modified, and in a way that suggests that the authors of Ansible thought it was a good idea.

The top-level playbook:

---
#skippedinclude.yml

- name: modify variables via included files, showing that skipping them still affects variables
  hosts: localhost
  roles:
    - role: skippedinclude

The roles/skippedinclude folder includes several files

---
# roles/skippedinclude/vars/main.yml

var_one: one
var_two: two
var_three: three
---
# roles/skippedinclude/tasks/main.yml

- name: show us the value of pre-set variables from vars/main.yml
  debug:
    msg: 'var_one is "{{ var_one }}", var_two is "{{ var_two }}", var_three is "{{ var_three }}"'

- name: include a file, but skip it, that would reset var_one
  include: modify_var_one.yml
  when: False

- name: show us the variable values again
  debug:
    msg: 'var_one is "{{ var_one }}", var_two is "{{ var_two }}", var_three is "{{ var_three }}"'

- name: include the same file to reset var_one, actually running it this time
  include: modify_var_one.yml
  when: True

- name: show us the variable values again
  debug:
    msg: 'var_one is "{{ var_one }}", var_two is "{{ var_two }}", var_three is "{{ var_three }}"'

- name: skip an include that would modify (via register:) var_three
  include: modify_var_three.yml
  when: False

- name: show us the variable values again
  debug:
    msg: 'var_one is "{{ var_one }}", var_two is "{{ var_two }}", var_three is "{{ var_three }}"'
---
# roles/skippedinclude/tasks/modify_var_one.yml

- name: announce ourselves
  debug:
    msg: "This is the modify_var_one.yml file in the skippedinclude role"

- name: reset variable var_one
  set_fact:
    var_one: '{{ var_three }}_a'
---
# roles/skippedinclude/tasks/modify_var_three.yml

- name: register a variable, replacing the var_three variable
  shell: 'echo "3"'
  register: var_three

Ansible output 1:

Script started on Sun 06 Aug 2017 11:18:44 AM EDT
giles:~..e/Ansible/tests$ ansible-playbook skippedinclude.yml
 [WARNING]: provided hosts list is empty, only localhost is available


PLAY [modify variables via included files, showing that skipping them still affects variables] ***

TASK [Gathering Facts] *********************************************************
ok: [localhost]

TASK [skippedinclude : show us the value of pre-set variables from vars/main.yml] ***
ok: [localhost] => {
    "msg": "var_one is \"one\", var_two is \"two\", var_three is \"three\""
}

TASK [skippedinclude : announce ourselves] *************************************
skipping: [localhost]

TASK [skippedinclude : reset variable var_one] *********************************
skipping: [localhost]

TASK [skippedinclude : show us the variable values again] **********************
ok: [localhost] => {
    "msg": "var_one is \"one\", var_two is \"two\", var_three is \"three\""
}

TASK [skippedinclude : announce ourselves] *************************************
ok: [localhost] => {
    "msg": "This is the modify_var_one.yml file in the skippedinclude role"
}

TASK [skippedinclude : reset variable var_one] *********************************
ok: [localhost]

TASK [skippedinclude : show us the variable values again] **********************
ok: [localhost] => {
    "msg": "var_one is \"three_a\", var_two is \"two\", var_three is \"three\""
}

TASK [skippedinclude : register a variable, replacing the var_three variable] ***
skipping: [localhost]

TASK [skippedinclude : show us the variable values again] **********************
ok: [localhost] => {
    "msg": "var_one is \"three_a\", var_two is \"two\", var_three is \"{'skip_reason': u'Conditional result was False', 'skipped': True, 'changed': False}\""
}

PLAY RECAP *********************************************************************
localhost                  : ok=7    changed=0    unreachable=0    failed=0

giles:~..e/Ansible/tests$

The first skipped role only uses set_fact: to modify variables, and they come out of that process unscathed. But you'll see that var_three had the string value of "three" before we "skip" the included file modify_var_three.yml, but after we skip that file, it's been set as a complex variable that tells us nothing useful except that skipping happened. Wait, what? The fact that the resulting variable mentions skipping suggests to me that the Ansible authors are aware of the behaviour and actually think it's a good idea - preferable to, say, leaving the variable alone which almost any programmer from another language would have expected ...

I haven't found a good work-around for this. I ran up against it when I tried to run two different includes dependent on the version of the installed software. I ran one directly after the other, with both setting the same variables. I assumed that the one that was skipped would leave the variables alone ... because that's what if-then-else does. But this is no ordinary programming language ...