DevOps engineer reviewing Ansible failed_when conditions and an AND versus OR truth table on a computer screen

Ansible Can Report OK When Only One Failure Condition Matches

Wed, Nov 4, 2026

A02 is the case that should make a reviewer stop: the owned probe exits with return code 7, the registered Ansible result still contains rc: 7, but the task is classified failed: false. The playbook process exits 0, and an ordinary next task runs far enough to create its continuation receipt.

Nothing rewrote the command's return code. The mismatch is in the authored failure policy.

The requirement in this laboratory is explicit: fail when the command returns nonzero OR when stderr contains the exact owned marker OWNED_ERROR. Yet the defective implementation supplies those two predicates as a failed_when list. Ansible documents that multiple list entries are combined with implicit and; an any-condition rule instead needs an explicit or expression.

The supplied preparation run exercised that distinction on ansible-core 2.19.3, Python 3.12.14, and Linux. That is a historical reproducibility baseline, not a statement about the newest Ansible release. The experiment is deliberately local and synthetic: one inventory alias, one owned probe, temporary files, no remote service, no cloud account, no privileged deployment operation, and no authorization for follow-on work.

The objective is narrower than "is this playbook safe?" It is to qualify one boolean policy against all four combinations of two independently controlled signals, while keeping the probe's return code, Ansible's failed classification, the outer playbook exit, and actual ordinary continuation separate.

Define what must count as failure

Start with the policy, not the YAML representation.

For this fixture, define two predicates:

R: the owned probe's return code is nonzero.

M: stderr contains the exact marker OWNED_ERROR.

The policy owner has declared:

FAIL = R OR M

Therefore the command should be accepted only when both signals are absent.

Probe rc

OWNED_ERROR

R

M

Declared policy says fail

0

absent

false

false

false

7

absent

true

false

true

0

present

false

true

true

7

present

true

true

true

This truth table is independent of the implementation. That separation matters. If expected results are derived from whatever Ansible happened to report, the test merely restates its observations and cannot expose a defective predicate.

A single happy-path row cannot distinguish conjunction from disjunction. When both predicates are false, R AND M and R OR M are both false. Likewise, the both-signals row does not distinguish them: both expressions are true when R=true and M=true. The decisive inputs are the two asymmetric rows in the middle, where exactly one signal is present.

That is why A02 and A03 carry more diagnostic value than either A01 or A04 for this particular review.

Give each signal a separate meaning

rc is an integer produced by the owned program. OWNED_ERROR is a deliberately authored protocol marker carried on stderr. Those meanings belong to this fixture; they are not universal command semantics.

A real command owner might define selected nonzero codes as expected outcomes, or might write informational text to stderr while still succeeding. Before adapting these predicates, the command owner must define what its exit codes and output markers mean. Ansible's ability to override failure classification exists precisely because a nonzero status does not necessarily have one universal application meaning.

Separate command return, task failure, and playbook exit

The most common interpretation error here is collapsing several statuses into one.

Ansible's common return-value documentation distinguishes rc, which for command-executing modules contains the invoked utility's return code, from failed, which is a boolean indicating Ansible's task classification. stderr, stdout, and changed are separate result fields as well, and Ansible notes that some registered keys may be set after it processes the module's return information.

A02 demonstrates why those fields should stay separate:

probe rc                 = 7
registered failed        = false
registered changed       = false
playbook process exit    = 0
ordinary next task ran   = yes

The rc: 7 survives intact. The unexpected part, relative to the declared OR policy, is failed: false. Because the task was not classified as failed, normal execution reaches the subsequent copy task, and the playbook itself returns success.

changed_when: false adds another distinction. It controls the probe task's change reporting. It does not prevent the command from executing, erase its return code, suppress its output, or make the command side-effect-free. Ansible's failure-handling documentation treats changed_when and failed_when as separate classification controls.

That narrower separation also matters in broader infrastructure automation. Terraform provisioning, Ansible configuration, and process-level status are different responsibilities; readers who need that wider architectural context can review how Terraform and Ansible divide infrastructure responsibilities. The experiment here does not revisit that architecture. It asks only whether Ansible classified one already-executed command according to its declared policy.

Isolate the Ansible run from production state

The laboratory removes unrelated infrastructure variables rather than pretending to reproduce production.

The generated JSON inventory contains exactly one alias, owned, configured with ansible_connection: local and an explicit ansible_python_interpreter pointing to the interpreter executing the driver. Each play disables fact gathering and privilege escalation, selects the linear strategy, and the ansible-playbook invocation uses --forks 1.

The driver creates a private temporary root and supplies its own Ansible configuration, controller and remote temporary directories, collection path, action-plugin path, callback-plugin path, and library path. Before inserting those values, it removes inherited environment variables whose names begin with ANSIBLE_.

That is controlled, but it is not hermetic. Other environment variables remain inherited. The fixture therefore should not be described as proving behavior independent of locale, process environment, filesystem implementation, platform details, or future dependency changes.

Each case receives a fresh directory. Inside it, continued.txt is the ordinary-next-task receipt, task-result.json retains the registered probe result, and playbook.json records the exact generated play. The executable probe.py, inventory, and configuration are also generated inside the temporary root.

Every probe executes normally. There is no simulation flag, creates sentinel, or check-mode interpretation in the matrix. The separate question of which Ansible tasks actually ran in check mode belongs to a different validation problem and is intentionally excluded here.

Record the tested version without calling it current

The source enforces ansible-core 2.19.3. The supplied preparation report records Python 3.12.14; Jinja2 3.1.6; PyYAML 6.0.3; cryptography 50.0.2; packaging 26.3; resolvelib 1.2.1; MarkupSafe 3.0.4; cffi 2.1.1; pycparser 3.12; Linux 6.18.57 x86_64 with glibc 2.39; and UID 0.

Those are execution attributes, not recommendations. Preparation as UID 0 does not establish ordinary-user permission behavior.

The Ansible 2.19 manual is a versioned semantic reference; rolling Ansible documentation and the maintained Python 3.12 documentation are separately identified sources. Accessing those manuals on November 4, 2026 does not make that date a software release date.

Run the complete local matrix

For a standalone Linux reproduction with Python 3.12, save the following source as cloud_ansible_fixture.py and create an isolated environment using the recorded package versions.

python3.12 -m venv .venv
.venv/bin/python -m pip install ansible-core==2.19.3 Jinja2==3.1.6 PyYAML==6.0.3 cryptography==50.0.2 packaging==26.3 resolvelib==1.2.1 MarkupSafe==3.0.4 cffi==2.1.1 pycparser==3.12
.venv/bin/python cloud_ansible_fixture.py > cloud_ansible_results.json

The complete supplied source is autonomous: it generates its probe, one-host inventory, ten playbooks, evidence files, assertions, and cleanup.

"""Owned Ansible failure-policy matrix; no service, remote host or deployment."""
import hashlib
import importlib.metadata
import json
import os
import platform
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path

import ansible.release

# id, rule, command rc, error marker, expected task failure, intended policy failure
CASES = [
    ('A01', 'list_and', 0, False, False, False),
    ('A02', 'list_and', 7, False, False, True),
    ('A03', 'list_and', 0, True, False, True),
    ('A04', 'list_and', 7, True, True, True),
    ('A05', 'explicit_or', 0, False, False, False),
    ('A06', 'explicit_or', 7, False, True, True),
    ('A07', 'explicit_or', 0, True, True, True),
    ('A08', 'explicit_or', 7, True, True, True),
    ('A09', 'default_rc', 7, False, True, True),
    ('A10', 'default_rc', 0, True, False, True),
]
PROBE = """import json, sys
identity, code, marker = sys.argv[1:]
print(json.dumps({'case': identity, 'executed': True}))
if marker == 'yes':
    print('OWNED_ERROR', file=sys.stderr)
sys.exit(int(code))
"""

def require(ok, message):
    if not ok:
        raise RuntimeError('HOLD: ' + message)

def run(report):
    require(ansible.release.__version__ == '2.19.3', 'ansible-core 2.19.3 required')
    with tempfile.TemporaryDirectory(prefix='owned-ansible-policy-') as name:
        root = Path(name)
        for folder in ('controller-tmp', 'remote-tmp', 'collections', 'plugins'):
            (root / folder).mkdir()
        config = root / 'ansible.cfg'
        config.write_text('[defaults]\nstdout_callback=default\n'
                          'retry_files_enabled=False\nhost_key_checking=True\n')
        inventory = root / 'inventory.json'
        inventory.write_text(json.dumps({'all': {'hosts': {'owned': {
            'ansible_connection': 'local', 'ansible_python_interpreter': sys.executable
        }}}}))
        probe = root / 'probe.py'
        probe.write_text(PROBE)
        env = {k: v for k, v in os.environ.items() if not k.startswith('ANSIBLE_')}
        env.update(ANSIBLE_CONFIG=str(config), ANSIBLE_NOCOLOR='1',
                   ANSIBLE_LOCAL_TEMP=str(root / 'controller-tmp'),
                   ANSIBLE_REMOTE_TEMP=str(root / 'remote-tmp'),
                   ANSIBLE_COLLECTIONS_PATH=str(root / 'collections'),
                   ANSIBLE_ACTION_PLUGINS=str(root / 'plugins'),
                   ANSIBLE_CALLBACK_PLUGINS=str(root / 'plugins'),
                   ANSIBLE_LIBRARY=str(root / 'plugins'),
                   LC_ALL='C.UTF-8')
        for identity, rule, code, error_marker, expected_failed, policy_failed in CASES:
            work = root / identity
            work.mkdir()
            marker, evidence = work / 'continued.txt', work / 'task-result.json'
            task = {'name': 'Run the owned probe',
                    'ansible.builtin.command': {
                        'argv': [sys.executable, str(probe), identity, str(code),
                                 'yes' if error_marker else 'no']},
                    'register': 'probe_result', 'changed_when': False}
            if rule == 'list_and':
                task['failed_when'] = ['probe_result.rc != 0',
                                      "'OWNED_ERROR' in probe_result.stderr"]
            elif rule == 'explicit_or':
                task['failed_when'] = (
                    "probe_result.rc != 0 or 'OWNED_ERROR' in probe_result.stderr")
            play = [{'name': identity, 'hosts': 'all', 'connection': 'local',
                     'gather_facts': False, 'become': False, 'strategy': 'linear',
                     'tasks': [{'name': 'Observe failure classification and continuation',
                         'block': [task, {
                             'name': 'Record that the next task was reached',
                             'ansible.builtin.copy': {
                                 'dest': str(marker), 'content': identity + '\n',
                                 'mode': '0600'}}],
                         'always': [{
                             'name': 'Retain the registered probe result',
                             'ansible.builtin.copy': {
                                 'dest': str(evidence),
                                 'content': '{{ probe_result | to_json }}',
                                 'mode': '0600'}}]}]}]
            playbook = work / 'playbook.json'
            playbook.write_text(json.dumps(play, indent=2))
            command = [sys.executable, '-m', 'ansible.cli.playbook',
                       '-i', str(inventory), '--forks', '1', str(playbook)]
            p = subprocess.run(command, cwd=root, env=env, stdin=subprocess.DEVNULL,
                               capture_output=True, text=True, timeout=60)
            report['commands'].append({'case': identity, 'argv': command,
                'returncode': p.returncode, 'stdout': p.stdout, 'stderr': p.stderr})
            require(evidence.is_file(), identity + ' registered result missing')
            actual = json.loads(evidence.read_text())
            require(actual['rc'] == code, identity + ' command return code')
            require(actual['stderr'] == ('OWNED_ERROR' if error_marker else ''),
                    identity + ' stderr literal')
            require(json.loads(actual['stdout']) == {'case': identity, 'executed': True},
                    identity + ' probe execution receipt')
            require(actual['failed'] is expected_failed, identity + ' task failure')
            require(actual['changed'] is False, identity + ' changed flag')
            require((p.returncode != 0) is expected_failed, identity + ' playbook status')
            continued = marker.is_file()
            require(continued is (not expected_failed), identity + ' continuation')
            if continued:
                require(marker.read_text() == identity + '\n', identity + ' marker bytes')
            decision = ('MATCHES_THIS_ROW' if actual['failed'] is policy_failed
                        else 'MISCLASSIFIES_DECLARED_POLICY')
            report['cases'].append({'case': identity, 'rule': rule,
                'input_rc': code, 'input_error_marker': error_marker,
                'expected_task_failed': expected_failed,
                'declared_policy_failed': policy_failed,
                'registered_result': actual, 'next_task_reached': continued,
                'playbook_returncode': p.returncode, 'decision': decision, 'status': 'PASS'})
        require([row['case'] for row in report['cases']] ==
                ['A%02d' % i for i in range(1, 11)], 'complete inventory')
    require(not root.exists(), 'normal temporary cleanup')
    report.update(status='PASS_10_CASES', normal_temporary_cleanup_completed=True)

if name == '__main__':
    report = {'status': 'HOLD', 'started_utc': datetime.now(timezone.utc).isoformat(),
              'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
              'python': platform.python_version(), 'platform': platform.platform(),
              'uid': os.getuid(), 'packages': {p: importlib.metadata.version(p) for p in
                  ('ansible-core', 'Jinja2', 'PyYAML', 'cryptography', 'packaging',
                   'resolvelib', 'MarkupSafe', 'cffi', 'pycparser')},
              'cases': [], 'commands': []}
    try:
        run(report)
    except Exception as error:
        report['error'] = {'type': type(error).__name__, 'message': str(error)}
    report['finished_utc'] = datetime.now(timezone.utc).isoformat()
    print(json.dumps(report, indent=2))
    sys.exit(0 if report['status'] == 'PASS_10_CASES' else 1)


The source digest supplied with the preparation is SHA-256 655cb66e42bf56fd89d4d93bafc89332843f741f7eb42124530dd86519703c9b, covering 7,644 bytes. A digest identifies source bytes; it is not proof that a particular execution happened.

The recorded run spans outer-report timestamps 2026-11-04T08:20:36.730340+00:00 through 2026-11-04T08:21:08.869823+00:00, finished as PASS_10_CASES, and observed normal temporary cleanup. The preparation history also records one earlier setup attempt that failed before probe evidence because an empty callbacks_enabled configuration entry was interpreted as an empty plugin name. Removing that entry produced the exact final source and completed ten-case run; that setup failure is not itself a successful test.

For acceptance, the diagnostic driver's process exit must be 0; its report must say PASS_10_CASES; the digest must match; A01 through A10 must all be present in order; and their ledger must satisfy the literal assertions. Installation failure, missing JSON, a partial case set, inconsistent evidence, or an unexpected environment is HOLD.

The ansible.builtin.command task uses argv, which the command-module documentation defines as list-form arguments rather than shell syntax. The module also documents return fields including rc, stdout, and stderr, and its default strip_empty_ends behavior explains why the probe's printed newline is not retained after OWNED_ERROR.

Do not aim this fixture at an existing inventory, and do not replace its harmless continuation copy with a real operational action.

Read the four list-condition cases

A01 through A04 keep the same failed_when list:

failed_when:
  - probe_result.rc != 0
  - "'OWNED_ERROR' in probe_result.stderr"

Ansible documents the crucial semantic rule: multiple failed_when entries in a list are joined using implicit and. The task therefore fails only when both expressions evaluate true.

A01 has rc=0 and no marker. Both predicates are false. The task is correctly nonfailed under both the implementation and the declared OR requirement.

A04 has rc=7 and the marker. Both predicates are true. The task fails. Again, the implementation and the intended policy happen to agree.

Those agreement rows can be misleading if reviewed alone.

A02 has rc=7, no marker. Its first predicate is true and its second false. The conjunction therefore evaluates false. The supplied registered result retains rc: 7, reports failed: false, and the ordinary next task writes continued.txt. The playbook process exits 0.

A03 is the mirror image: rc=0, but stderr contains OWNED_ERROR. The first predicate is false and the second true. Again the conjunction evaluates false, so the registered result says failed: false; continuation occurs and the playbook exits 0.

Under the authored OR requirement, both rows should fail.

The command return code in A02 has not been transformed into zero. Its meaning was overridden at the task-classification layer by the custom predicate. That distinction is visible because the ledger preserves both rc and failed.

A01 and A04 therefore demonstrate only that two individual rows agree. They cannot qualify the list implementation for the four-row policy. A02 and A03 reject it.

Express OR and compare the same four inputs

A05 through A08 change one thing that matters: the failure expression becomes one scalar boolean expression containing explicit or.

failed_when: probe_result.rc != 0 or 'OWNED_ERROR' in probe_result.stderr

The registered variable is unchanged. The two operands are unchanged. The return-code population remains 0 and 7. The marker remains the exact literal OWNED_ERROR. The command still executes through ansible.builtin.command with argv, changed_when: false, no check mode, and the same continuation/evidence structure.

This is the comparison a policy review needs because it avoids changing the probe while changing the boolean connective.

A05 has neither signal and stays nonfailed. A06 has only rc=7 and fails. A07 has only the owned marker and fails. A08 has both and fails. In the supplied preparation, all four classifications match the independently authored OR truth table.

For A06, A07, and A08, ordinary continuation is absent, and each playbook process exits nonzero. The observed numeric status is 2; the harness intentionally checks only that the status is nonzero rather than declaring 2 a universal Ansible failure code.

This behavior is also exactly what Ansible's failure-handling manual describes: list-form conditions imply conjunction, while failure on any of multiple conditions should be written as a single expression with explicit or.

That does not make explicit OR a universal repair for every failed_when list. If a requirement genuinely says "fail only when condition one and condition two are both present," list conjunction directly represents that policy.

The repair is to make the expression match the requirement, not to mechanically replace lists with scalar OR expressions.

Qualify the complete rule, not a matching row

QUALIFIED_FOR_THIS_TRUTH_TABLE means all four input combinations agree with the separately reviewed requirement on the stated baseline.

It does not mean one clean row passed, nor that the both-signals row failed as expected. The middle rows are necessary because only they distinguish AND from OR.

If the policy owner changes the requirement to "fail only when both signals occur," then the oracle changes. Under that policy, conjunction could be correct and the OR implementation could instead become overbroad. Requirement ownership therefore precedes syntax repair.

Use default command behavior as a diagnostic control

A09 and A10 remove failed_when rather than inventing another custom rule.

A09 executes the probe with rc=7 and no marker. Without a custom failure predicate, the command task is classified failed, the next ordinary task does not run, and the recorded playbook exit is 2.

That is useful because it shows what the custom list changed in A02. The same owned return code is present in both cases: A02's list overrides task classification and permits continuation; A09's default command behavior classifies the nonzero command result as failed. Ansible documents that its default handling stops normal execution for a host after a command returns nonzero, subject to the error-control mechanisms a playbook may deliberately configure.

A10 uses the other asymmetric signal: rc=0 with OWNED_ERROR on stderr. With no custom failed_when, the task remains nonfailed and the ordinary next task runs.

That is not a defect in the command module. The marker has failure meaning only because this laboratory's declared application policy assigns it that meaning.

Ansible's command-module documentation exposes stderr as returned data, but does not define arbitrary stderr text as automatic task failure.

Together, A09 and A10 isolate the reason for the explicit policy. Default return-code handling catches the nonzero-only row, while it has no knowledge of this fixture's marker-only protocol.

Do not generalize either direction. A nonzero return code can have command-specific semantics; likewise, merely writing text to stderr does not universally mean an application operation failed. The owner of the invoked utility defines those semantics, and the playbook's predicate should encode the approved interpretation.

Reconcile the complete evidence ledger

The supplied preparation recorded the following expected-versus-observed outcomes. Every probe stdout receipt decoded to its own case ID with executed: true, and every probe task had changed: false.

Case

Rule

Probe rc

Marker

Expected failed

Observed failed

Next task reached

Playbook exit

Policy interpretation

A01

List AND

0

absent

false

false

yes

0

Matches this row

A02

List AND

7

absent

false

false

yes

0

Misclassifies intended OR

A03

List AND

0

present

false

false

yes

0

Misclassifies intended OR

A04

List AND

7

present

true

true

no

2

Matches this row

A05

Explicit OR

0

absent

false

false

yes

0

Matches this row

A06

Explicit OR

7

absent

true

true

no

2

Matches this row

A07

Explicit OR

0

present

true

true

no

2

Matches this row

A08

Explicit OR

7

present

true

true

no

2

Matches this row

A09

Default rc

7

absent

true

true

no

2

Matches this row

A10

Default rc

0

present

false

false

yes

0

Misclassifies intended OR

There are two different notions of "expected" in the fixture, and conflating them would destroy the experiment.

expected_task_failed predicts the semantics of the implementation selected for that case. For A02 and A03 that value is deliberately false, because a list means AND and only one predicate is true. A10 is likewise expected to remain nonfailed because it has no custom marker-aware rule.

declared_policy_failed is the independent oracle. It is true whenever either failure signal is present.

Consequently, the test driver can legitimately finish PASS_10_CASES while containing cases classified MISCLASSIFIES_DECLARED_POLICY. PASS means the fixture observed every case exactly as designed. It does not approve every tested implementation.

That distinction is essential in diagnostic testing: an expected bad control should pass its test when it reproduces the known-bad classification.

The observed 2 for failed playbooks is evidence from this preparation, not an API promise promoted by the article. The harness's assertion is intentionally (p.returncode != 0) is expected_failed, preserving the boundary between the observed numeric result and the general condition the experiment actually needs.

Preserve evidence without rescuing the failure

Each generated play contains a normal block followed by always.

Inside the normal block, the probe runs first. If Ansible classifies it nonfailed, the ordinary continuation copy runs next. The always section then serializes probe_result into task-result.json.

If the probe is classified failed, normal execution inside that block stops before the continuation copy, but the always evidence task still runs for this valid local playbook. Ansible documents always as the part of block error handling that runs irrespective of the preceding block's task status; it separately documents that malformed task definitions and unreachable hosts are outside ordinary rescue behavior.

There is no rescue in this fixture. There is also no ignore_errors, no blanket failed_when: false, and no mechanism that deliberately converts the probe failure into a successful operational path.

That architecture lets the fixture retain evidence without confusing retention with recovery.

Three result channels provide different facts. stdout is parsed as JSON and must contain the current case ID plus executed: true, establishing an owned execution receipt. stderr must exactly equal OWNED_ERROR when the marker was requested, or an empty string otherwise. rc must equal the supplied code.

A new case directory is created before every playbook. Therefore a stale continued.txt from A01 cannot satisfy A02, and evidence from A06 cannot impersonate A07.

Treat absent evidence as HOLD

Missing evidence is not converted into an empty passing value.

The driver requires task-result.json to exist before it interprets the registered result. It then checks rc, stderr, stdout JSON, failed, changed, outer status, continuation, and, where continuation exists, the marker's exact bytes.

The recorded earlier configuration error is a useful boundary: it failed before probe evidence and therefore did not become a test result. The completed source ran only after that setup defect was removed.

This does not prove evidence survival after malformed tasks, unreachable hosts, controller crashes, interrupted writes, timeout exceptions, filesystem loss, or machine failure. always is useful block control flow, not a durable journal or transactional recovery mechanism. Missing, partial, or inconsistent evidence means HOLD.

Define precisely what the next task proves

continued.txt answers one narrow question: did this one ordinary next Ansible task execute far enough to write its expected case identity?

For A01, A02, A03, A05, and A10, the receipt exists and contains that case's ID followed by a newline. For A04, A06, A07, A08, and A09, it is absent because the preceding probe was classified failed and normal progression through the block stopped.

That is stronger evidence than inferring continuation only from the play recap: the fixture tests for the artifact produced by the actual next task. It is still intentionally bounded.

The receipt does not prove that a daemon reloaded, an application consumed new state, a health check passed, a rollback succeeded, an external customer saw a change, a transaction committed, or an operator approved anything. There is no service in this laboratory.

Likewise, the continuation copy and the always evidence-writing copy are allowed to report changes to their temporary files. changed_when: false belongs only to the probe task. It must not be read as a play-wide assertion that nothing changed.

That distinction also keeps this article separate from the separate handler failure and rerun problem. Handler notification, delayed activation, forced handlers, and rerun state reconciliation ask what occurs after changed tasks and later failures. This matrix has no handler and no simulated active configuration. Its evidence stops at classification of the probe and immediate ordinary continuation.

For the policy owner, the continuation file matters because A02 and A03 do more than display an aesthetically surprising ok. They demonstrate that, under the authored list rule, execution proceeded into an ordinary subsequent task when the declared OR policy said it should not.

That is the operational consequence being qualified, nothing more.

Capture the outer process result without normalizing it

The Python driver invokes each playbook using an explicit argument vector:

command = [
    sys.executable,
    '-m',
    'ansible.cli.playbook',
    '-i',
    str(inventory),
    '--forks',
    '1',
    str(playbook),
]

It then calls subprocess.run() with capture_output=True, text=True, a 60-second timeout, and the prepared environment.

Python 3.12 documents that run() waits for the child and returns a CompletedProcess; with capture_output=True, stdout and stderr are captured separately. Its default is check=False, so a nonzero child status can be returned to the caller for inspection rather than automatically becoming CalledProcessError. A timeout instead raises TimeoutExpired, which is why a timeout would be incomplete evidence rather than another ordinary failing-playbook row.

The fixture retains each command's argument vector, returncode, stdout, and stderr in report['commands'].

That allows expected failed playbooks to remain visibly nonzero. The driver does not normalize A06's playbook failure to zero before checking it. Instead, it verifies the nonzero result as expected evidence, completes the remaining synthetic cases, and eventually returns zero only if the entire ten-case diagnostic run satisfies its assertions.

This yields two perfectly compatible facts:

A06 ansible-playbook process: nonzero
diagnostic matrix driver:     zero after all ten expected outcomes pass

That is not error suppression. It is the distinction between the system under test and the test runner.

Shell wrappers can lose that distinction for their own reasons; Bash declarations that hide a command failure covers one such problem separately. This fixture deliberately avoids expanding into pipelines, local declaration semantics, or errexit.

Keep three statuses and two clocks separate

The evidence contains three status layers: the owned probe's rc; the ansible-playbook child process's return code; and the final diagnostic driver's process result.

They answer different questions.

There are also different timestamp forms. The outer report uses timezone-aware datetime.now(timezone.utc).isoformat() values. Registered command-module data may contain start and end strings, but Ansible's documented examples show those fields without an explicit zone.

Do not silently label unzoned module timestamps UTC, infer cross-host ordering from them, or turn this roughly 32-second preparation into a performance benchmark. Timing is incidental to this classification experiment.

Choose repair, requirement review, or HOLD

The correct action depends first on the approved requirement and then on whether complete evidence supports the implementation.

Decision

Evidence needed

Owner action

QUALIFIED_FOR_THIS_TRUTH_TABLE

All four independent inputs present; observed classification matches the approved oracle in every row; expected continuation agrees; baseline is identified

Accept only this predicate-versus-truth-table result for the stated environment and scope

REPAIR_POLICY

Approved requirement is OR, but implementation uses conjunction or otherwise disagrees with one or more rows

Change the predicate to represent the approved rule, then requalify all four combinations

REVIEW_REQUIREMENT

It is unresolved whether either signal or both signals should cause failure

Command owner and policy owner define semantics before changing YAML

HOLD

Missing or partial report, wrong runtime, missing case, inconsistent registered fields, unexplained result, install/setup failure, or failed fixture assertion

Stop interpretation, preserve evidence, resolve the discrepancy, and produce a newly labelled run

For the supplied requirement (nonzero rc OR OWNED_ERROR), the list implementation earns REPAIR_POLICY. The explicit-OR population A05–A08 earns QUALIFIED_FOR_THIS_TRUTH_TABLE on the recorded baseline because every one of the four combinations matches the separate requirement oracle.

If the organization instead intends failure only when both signals occur, the answer changes to REVIEW_REQUIREMENT followed by a potentially valid conjunction. OR should never be installed merely because it was the repair in this experiment.

HOLD also has an operational purpose: it prevents absence of trustworthy observations from being converted into presumed success.

Finally, a policy repair is prospective. Editing failed_when cannot reverse side effects that some real command may have caused during an earlier run. This laboratory's commands touch only owned temporary files and implement no production recovery, rollback, deployment approval, or distributed transaction.

Assign ownership and requalify after policy changes

A robust failure rule has several owners because no single field contains the entire contract.

The command owner owns the meaning of exit codes and the marker protocol. If return code 7 means "operation rejected," that needs to be explicit. If it later means "nothing required," the playbook oracle changes. The same applies to OWNED_ERROR: a diagnostic marker cannot safely be treated as a failure protocol merely because its spelling looks serious.

The playbook owner translates that approved command contract into failed_when. For this requirement, that is a disjunction:

failed_when: probe_result.rc != 0 or 'OWNED_ERROR' in probe_result.stderr

If the contract is conjunction, the list can be appropriate:

failed_when:
  - probe_result.rc != 0
  - "'OWNED_ERROR' in probe_result.stderr"

The reviewer owns the comparison between policy and implementation. That requires all four rows, not visual inspection of readable YAML. The important review question is not "does this look reasonable?" but "which boolean function does this syntax actually implement?"

The test runner owns completeness and attribution: case inventory, literal expectations, environment identity, preservation of observed return values, and failure when evidence is missing.

Those responsibilities sit within the wider operating discipline described in the broader responsibilities of automation-focused system administrators, but this experiment deliberately keeps its acceptance boundary much smaller.

Requalification is warranted when a material part of the contract changes: command exit-code semantics, marker wording or output stream, the failed_when expression, Ansible or relevant dependency versions, or execution mode. A different runtime can produce useful evidence, but it must be labelled as a new observation rather than retroactively appended to the ansible-core 2.19.3 preparation.

The expectation columns should remain literal and independently reviewed. Do not regenerate "expected" values from fresh observations, because that makes disagreement structurally impossible.

Source provenance deserves similar discipline. The supplied file's digest identifies the bytes whose preparation results are being discussed. It does not cryptographically attest the execution host, timestamp, operator, or resulting ledger. Conversely, a future rerun with a different digest or environment should carry its own provenance rather than silently replacing this one.

Normal temporary cleanup was observed at the end of the final preparation. That supports exactly one statement: the fixture's normal path removed its temporary root after the cases completed. It does not establish cleanup after power loss, process termination, controller failure, or interrupted filesystem operations.

Change the contract and its tests together

An intentional requirement change can make AND correct.

If owners decide the task should fail only when rc != 0 and the marker is present, update the written policy first, update the independent expected truth table, and then test the same four-signal population against that requirement.

Keep negative controls visibly negative instead of deleting the rows that distinguish competing expressions. A diagnostic suite is valuable precisely because some tested implementations are expected to be rejected.

A rerun under another Ansible, Python, dependency set, privilege context, or execution configuration is new evidence. Requalify classification and continuation there; do not treat environmental similarity as proof that the old observations automatically extend.

Build the DevOps foundations behind deliberate failure handling

The practical decision is compact even though the evidence behind it should not be: define command semantics first, translate the approved boolean requirement accurately, test all four combinations of the two independent signals, retain rc and failed separately, and verify whether the ordinary next task actually ran.

For this laboratory's declared rule, the list implementation is rejected because A02 and A03 each contain one valid failure signal yet remain failed: false and continue. The explicit OR implementation matches the complete authored truth table on the supplied ansible-core 2.19.3 baseline. That earns QUALIFIED_FOR_THIS_TRUTH_TABLE, not deployment approval.

The underlying skill is broader than remembering one Ansible syntax rule. Infrastructure engineers need to move cleanly from a command contract, to a boolean predicate, to observable evidence, while resisting the temptation to collapse "command exited," "task failed," "playbook returned nonzero," and "next task ran" into one status.

For readers building those foundations, Refonte Learning's DevOps Engineering program currently lists a three-month format at 12–14 hours per week, with curriculum areas including Linux fundamentals and scripting, Git/GitHub, CI/CD, Docker and Kubernetes, Terraform, cloud platforms, monitoring and logging, and a capstone project. The page describes eligibility as being engaged in bachelor's or postgraduate studies and separately states that applicants should be working toward a bachelor's or higher-level degree.

The same page names MSc Oskar Eriksson as an educational mentor, describes virtual internship opportunities, and states that successful completion provides a Training Certificate and a Certificate of Internship. The page does not establish that this failed_when laboratory, or specialist Ansible failure-policy instruction, is part of the curriculum.

The immediate engineering requirement remains bounded: match the predicate to the declared policy, preserve the four-row oracle, and classify incomplete or contradictory evidence as HOLD. None of those decisions authorizes a real deployment, recovery action, or follow-on change.