Skip to content

fix: filter and capture handling in the query engine, the reporter aborts it exposed, and four parser boundaries - #727

Closed
awsmadi wants to merge 376 commits into
aws-cloudformation:mainfrom
awsmadi:pr/filters-captures-and-reporter-silence
Closed

awsmadi wants to merge 376 commits into
aws-cloudformation:mainfrom
awsmadi:pr/filters-captures-and-reporter-silence

Conversation

@awsmadi

@awsmadi awsmadi commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Continues #717, which was merged at 567c0d7. These are the commits made on that branch afterwards and so
not part of it. Rebased onto current main; the tree differs from the pre-rebase branch only in the
lockfiles ef17f36 patched.

Same class of defect as #717: a rule that answers compliance for a check that never ran, or a run that
exits non-zero and names no finding.

One CI job is expected to be red, and it is not this branch's doing.
aws-guard-rules-registry-integration-tests-* fails because a change here — failing a run when an
expectation names a rule that produces no verdict — is correct and the registry has 30 such expectations, all
in rules/aws/aws_cloudformation, naming 11 rules their file never defines. Measured: exactly 1 of 49
registry directories changes its exit code, and stdout is byte-identical over the whole corpus, 9,156 lines
either way. The fix is aws-cloudformation/aws-guard-rules-registry#288, which takes
cfn-guard test -d rules on that repository from exit 1 to exit 0 against this branch's binary. Both registry
jobs are also now pinned to a SHA instead of tracking main, so a commit in another repository can no longer
turn this one's CI red without a change here; read that pin as a lock on a known state rather than a green
one, and move it forward once #288 lands.

A typo could delete a rule and report compliance

extract_message searched all remaining input for the closing >>, so one forgotten tag consumed every
rule up to the next rule's tag as message text and those rules ceased to exist. A file whose second rule
the template violated reported PASS at exit 0 — no finding, no diagnostic on any channel, and a parse
tree holding only the first rule with the rest of the file inside its custom message.

A message is now delimited by the grammar the language actually uses, rather than by a bound on the search.
A >> on the opening line closes the message; otherwise the message ends at the first later line whose
trimmed text is exactly >>; otherwise it is the error this code already raised for a missing tag.

Two earlier attempts at this each bounded the search, and each was wrong. They are worth stating because
the shape of the mistake is the same both times. Stopping at the first line whose first token is } or rule
finds a forgotten tag whose next >> is in a later rule, but when the next >> belongs to the next clause of
the same block no such line sits between the tags, so that clause was still swallowed at exit 0 -- in a rule
body, in a type block and in a when block. It also rejected a legitimate body quoting example JSON, because
such a body has a line starting }. Stopping at the next << fixed all of those and broke a third case: with
no second << in the file the search ran on to a >> inside a comment and closed the message there, so

rule one {
    Resources.One.Type == "AWS::S3::Bucket" << closing tag forgotten
}
rule two {
    Resources.One.Properties.Encrypted == true
    # see the runbook for escalation >>
}
rule three {
    Resources.One.Properties.Public == true
}

parsed as rules one and three, with rule two absent from the parse tree, and exited 0 against a template rule
two violates. Taking the minimum of the two bounds does not work either: a comment carrying >> at the end of
the same block is silent under both, since neither a } line nor a second << sits between the tags.

Each of those bounds inferred document structure from a token that may legitimately appear inside a message
body. A terminator line infers nothing -- it is what a closing tag looks like in 231 of the 233 messages that
exist. The census is the whole basis for the design: of the 233 messages in the AWS rule registry and this
repository's fixtures, 231 are block form with the closing >> alone on its line, 2 are inline with both tags
on one line, nothing follows >> on its line in any of them, and there are no other shapes.

Two behaviour changes fall out. The JSON-quoting body now parses, because a brace in a body is text, so this
removes a limitation rather than documenting one. And a block body whose closing >> shares its line with body
text, as in << Violation: X then Fix: Y >>, is now a parse error. That is a narrowing, and it cannot be
avoided: the swallowed clause in the example above is itself a line ending in >>, so anything that accepts
the one accepts the other. No message in the corpus is written that way. It is asserted as a rejection so it
is not reopened by loosening the terminator.

All four broken shapes exit 5 now, for one reason rather than four. Measured against the previous bound over
2,494 fixture pairs and 276 rules files: zero exit-code, stdout and stderr differences, and byte-identical
parse trees, so all 233 messages parse exactly as before. Regression tests cover the four broken shapes, both
accepted forms, the empty message, and the rejectedthree shapes.

Five more readings that were not what the author wrote

  • A leading space changed the query part. [x] gave AllIndices and [ x ] gave AllValues, because
    the named branch of all_indices did not skip whitespace. Two spaces were then the difference between a
    working rule and an unevaluatable one: Tags.%k[x] == "prod" passed at exit 0 while Tags.%k[ x ] bailed
    at exit 19, since the %var interpolation arm accepts one query part and not the other.
  • when was a tag in front of a cut. It matched the first four characters of whenCreated, the
    whitespace requirement after it failed, and the cut made that unrecoverable — so any clause whose first
    identifier began when or WHEN was a parse failure. rule whenever { ... } defined fine and a
    whenever reference did not: a rule you could declare and never call.
  • A name assigned twice in one scope resolved by kind precedence, not order. let v = 1 then
    let v = 999 failed a rule that the reverse order passed, and two assignments of different kinds
    ignored their order entirely, because literals, queries and function calls go into separate maps consulted
    in a fixed order. Rejected now in both scopes. count.guard in this repository was a live instance: it
    bound res twice and asserted %res == 3 after each, so the first assertion tested the second count —
    and would have failed had it been live, because there are five properties and three collection entries.
    Both counts are now named and asserted.
  • keys was reserved for every clause rather than for key filters. A cut after the keyword stopped
    [ keys EXISTS ] from reaching the branch that parses it. The identical clause parsed one slot later,
    which is what showed nothing ambiguous forced the rejection.
  • in and the seven IS_* operators were plain tags. Size IS_INTEGER parsed as IS_INT plus a
    reference to a rule called EGER — loud with no such rule, PASS at exit 0 with one. The same guard
    true, false and null already had.

Three smaller ones: i64::MIN was the single integer the parser refused, a rule reference could not end its
block (rule b { a } was rejected while the same rule over three lines parsed), and the normative grammar
comment documented NOT_IN and a clause-level KEYS, neither of which was ever accepted.

The data loader, where every rule's answer starts

A data file of nothing but comments aborted the process. Exit 101,
panicked at guard/src/rules/libyaml/event.rs:63: not implemented, on four shapes: one comment line, the same
without a trailing newline, several comments with blank lines between them, and a fully commented-out template.
An empty file and a whitespace-only file already exited 255 saying the file is empty, so the degenerate case was
handled and this one was not. The event arriving at the wildcard arm is YAML_NO_EVENT, and it arrives because
the loop had no exit for a stream that never started a document: DocumentEnd was the only arm that returned
and StreamEnd shared the no-op arm, so the next pull ran past the end of the stream and libyaml answered it
with a success. StreamEnd now ends the loop and reports the file as holding no document, worded as the message
an empty file already gets. The wildcard arm remains and no longer aborts; it returns an error carrying the
event type, so a future miscount stays diagnosable.

True and TRUE were read as strings, so a gate never fired. Under
when Properties.PublicAccess == true, against a bucket with Encrypted: false: true, yes and on exit 19
and catch the violation, and True and TRUE exit 0 leaving it unchecked. is_bool_true and is_bool_false
matched lowercase only, while the null arm two lines below lowercased first, so within one function null was
case-insensitive and bool was not. YAML 1.1 makes all 22 spellings boolean; the 1.2 core schema admits six. The
loader already read yes, on and the single letters, which only 1.1 makes boolean, so the vocabulary was
already 1.1's with the capitalised spellings missing, and those four are boolean under both schemas so they were
missing whichever version was meant. test_handle_bool_happy_path has enumerated all 22 as the intended set
since it was written — and could not fail, because its assertion sat inside an if let MarkedValue::Bool(..) and
14 of the 22 cases passed while asserting nothing. handle_type_ref used str::parse::<bool>, so an explicit
!!bool yes was stricter than a bare yes; both now agree. Case-insensitive matching was rejected because
tRuE is boolean under neither schema, and narrowing to the 1.2 set was rejected because it would take yes
and on from documents that write them, which is a separate argument.

A query for Tags was answered out of Tag. A rule asking Properties.Tags.Name of a bucket with
Properties.Tag.Name reported a ComparisonError over /Properties/Tag/Name carrying that value: the rule
named one property and was answered about another. One-way, since Tag against Tags reported
MissingProperty. The cause is the class-case entry in the converter list the lookup falls back to when a key is
absent as written. cruet's class case is Rails' class-name rule, PascalCase and singular, so
to_class_case("Tags") is Tag; every other entry respells a key rather than naming a different one, and
to_pascal_case already covers PascalCase. It is not a plural rule either — Analysis becomes Analysi and
Metadata becomes Metadatum. Removing it costs no reach, measured by instrumenting the fallback: class case
won 57 lookups across the registry's 193 paired files and pascal, title and train produced the identical string
for all 57.

A duplicated key took the last value and said nothing. Encrypted: false followed by Encrypted: true
evaluates as true, the reverse order as false, and {"Encrypted":false,"Encrypted":true} the same way, all
with nothing on stderr. A reviewer reading a template top-down sees the first value and the tool judges the
last. YAML 1.2.2 makes this a loading failure rather than a merge — a mapping's keys must be unique per §3.2.1.1
and §3.2.1.3, and §3.3 lists non-unique keys among the loading failure points — so rejecting the document would
be defensible, and it would also fail every template carrying one today, which is a maintainer's call. This
reports the fact and leaves the verdict: one line naming the key's path and both locations, the last value still
winning, no exit code moved. The duplicate is not visible in the loader, which keys its maps on name and
location so both entries survive; they collapse a layer up where the map is rebuilt on the name alone, which is
also the only point that holds the path the key was reached by. Detection is per-mapping by construction, so a
property name repeated across two resources — which nearly every real template does — cannot register. Counted
before building: one occurrence in 71 corpus files, in the fixture that exists for it, and none in 256 registry
files. cfn-guard test still cannot report duplicates in its embedded inputs, because serde_yaml collapses a
repeated key during deserialization before any of this runs.

An unreachable!() in the FFI error table. get_code in guard-ffi answered Error::InternalError with
unreachable!(), in the table every error crossing the boundary is fed through and the same one that gained a
code for the no-document error above. It is not reachable through cfn_guard_run_checks as the code stands —
that path parses through serde and never touches the libyaml loader, the one construction site that is on the
path is mapped to ParseError first, and all four non-string key shapes come back as ParseError without
panicking. It is fixed regardless: Error and run_checks are public, the remaining variants are constructed
in a module this path avoids only because it writes through a BufWriter of its own, and a table total over
every variant should not rest on an unreachability the next commit can invalidate. The arm gets a code and the
table stays exhaustive rather than gaining a catch-all, since exhaustiveness is what surfaced it.

A leading space before a numeric index or a quoted key was a parse error, while every other bracket form
tolerated one. Names[0] and Names[0 ] parsed; Names[ 0], Names[ 0 ], Names[ -1 ] and
Resources[ "MyBucket" ] did not. This is the [x]-versus-[ x ] defect a second and third time: that fix
added whitespace skipping to the named branch of all_indices and left the numeric index and the quoted key
alone. The tests compare the resulting query part rather than merely that the file parses, because falling
through to a different part is how the first version of this defect did its damage.

Filters and captures in the query engine

[*] and .* both expand a map and hand each entry's value onward. That is right for a collection and
wrong for a single object whose fields the filter names: for
{ Statement: { Effect: Allow, Action: "s3:*" } } the values reaching the filter were the strings Allow
and s3:*, Effect == 'Allow' matched neither, and the filter selected nothing. An assertion over an empty
selection is a SKIP, so the two spellings disagreed — .* failed and [*] exited 0.

A filter capture leaked across iterations of a block. An iteration that captured nothing under a name fell
through to the parent scope, which by then held the earlier iterations' merged keys, so a non-compliant
resource passed on a compliant neighbour's key. Adding a compliant resource is what made the non-compliant
one pass, which is why such a rule looks correct when tested one resource at a time. BlockScope now carries
the capture names its own clauses declare, read out of the rule text rather than learned at runtime — the
first attempt learned them as keys were captured, which made the verdict depend on document order.

A bare bracket is a capture too, and was not counted as one. Properties.Tags[ tk ] written without a pipe
goes through all_indices and becomes AllIndices(Some("tk")), which captures the entry's key at retrieval
exactly as a filter does, but the name collector read Filter and MapKeyFilter only. No block declared the
name, so the lookup deferred to the parent, which held the keys earlier iterations had merged up. A resource
that captured nothing was credited with a previous resource's key and document order decided the verdict: with
the capturing clause in one when block and the reading clause in a sibling when block that only a resource
without Tags enters, one order exits 0 and the other 255. All orders now agree at 19. Pre-existing;
origin/main gives the same three codes, because no scope there held the name at all. The function's own doc
comment claimed AllValues and AllIndices were collected through arms of their own, and there were none.

Resources[*][ nm | ... ] then %nm ended the run at 255 while Resources[ nm | ... ] — the same filter
without the wildcard — worked. After a wildcard the map was already expanded when the filter ran, so the
name was discarded and the capture never made. The filter now runs at the expansion site, where the key is
still in hand.

Reporter aborts and findings with nowhere to go

Four reachable unreachable!() in the CloudFormation reporter and a reachable todo!() in the Terraform one
took the process down at exit 101, the latter on an everyday rule (IN against a plan). One was reachable
only after fixing the panic in front of it, which is why "not reproduced" is not the same as unreachable.

One rule that could not be evaluated discarded the whole file's report, in all four output paths and in
cfn-guard test: a file whose second rule read a variable that does not exist printed one error line and
threw away five real findings from its third rule.

Findings the per-resource output cannot place are reported under their own heading, Findings that belong to no resource:, each entry naming the rule it came from. The existing Could not be evaluated: is for a block
or rule that failed on a condition nobody could decide; these clauses were decided and merely have nowhere to
be printed, and filing a missing property under "could not be evaluated" would report an ordinary failure as
an undecidable one.

Which findings those are is decided by what the reporter rendered, not by predicting it from paths — and
the first version of this section predicted, wrongly, in both directions. It asked once per rule on the
premise that one placed sibling renders the whole rule; pprint_clauses in fact gates every clause
individually, so a rule with one located finding and one pathless one showed the first and lost the second
from the console entirely while the JSON carried its reason. And it could not see a block whose query failed
at the document root, because it required unresolved to be absent and MissingBlockValue is the one of
four block-report constructors that sets it — so a rule querying a top-level property against a
CloudFormation template exited 19 saying nothing, taking the author's own << >> message with it.

rendered_contexts is the union of the resource clause sets, which is by construction what the per-resource
output will show, and every clause and block is asked against it. Deciding per clause without that set
reintroduces a different fault, because the evaluator emits two reports for one comparison it resolved one
way and could not resolve another — the context set is what separates the unresolved twin from a genuinely
unreported sibling. Alongside: the author's << >> message now reaches the section (.or_else over the two
message slots was dead in its second half), NoValueForEmptyCheck no longer prints the negation of what
happened, and a rule name matched by prefix no longer renders one rule's block under another's findings.

Two further gaps in that section, both found by reviewing it rather than by a test. It keyed "was this shown
under a resource?" on the trimmed context string, so two clauses of one rule whose text is identical were
indistinguishable and the placed one made the pathless one count as shown; a rule asserting Properties.Tags exists both inside a resource block and at the document root reported one of the two and printed no section at
all. What separates that from the legitimate twin the context set exists to suppress took establishing: both are
two ClauseReport nodes, adjacent siblings, with byte-identical context and no recorded source span, and what
differs is the check carried, UnResolved beside InResolved for the twin against UnResolved beside
UnResolved for the collision. The gate is now node identity plus that one case named for what it is, and it
suppresses a strict subset of what the old one did, so a finding the old code printed cannot be lost.

And the 320-character cap on an explanation was applied before trimming, so a message's own indentation spent
its budget. A message is written inside the clause carrying it, so every line arrives indented to that clause's
depth, which made the length an author could write depend on how deeply the rule was nested; 9 of the corpus's
233 messages exceed the cap as written and only 5 do once trimmed. It now measures the content, and an
explanation that fits once trimmed comes out whole and unmarked, because an ellipsis is a claim that content was
dropped. One consequence is visible in four registry outputs: a short message no longer prints the whitespace
around it, so the evaluator's sentence joins it a line earlier rather than after a blank one. No exit code moves
and nothing is lost, but the separation between an author's message and the machine's reason was incidental and
is now absent.

Both console reporters also aggregated findings into a std::collections::HashMap and iterated it to write
the report. That hasher is seeded per process, so ten runs of one binary against a three-resource template
produced five distinct outputs.

Parser boundaries

Items[4294967296] wrapped to Items[0] and compared the wrong element. Non-finite float literals (1e400)
and literals that underflow to zero were accepted. Duplicate rule names and repeated parameter names were
accepted silently. A map key that reads as an integer was treated as an index rather than a key.

cfn-guard test reported success for suites it never ran

Three defects in the same family as the rest of this branch, found by following up why an expectation in the
AWS rule registry had never been checked.

A test suite could be run against the wrong rules file, and its failure reported as success. Test files
under a tests/ directory are paired with rules files by prefix, taking the first match in filename-sort
order, so a shorter stem claims a longer stem's tests. With s3.guard and s3_encryption.guard in one
directory, tests/s3_encryption_tests.yml went to s3.guard, which does not define the rule its expectations
name, and s3_encryption.guard was reported as having no tests associated. A suite that expects PASS where the
template violates the rule exits 7 under test -r/-t and exited 0 under test -d over the same two
files. Paired on the longest matching prefix now. The looseness is deliberate and is kept: requiring a _tests
suffix would stop <prefix>.yml and <prefix>-1.json pairing, and a character-class boundary cannot separate
the readings because _ is both the conventional separator and a word character. min_by_key over the
reversed length rather than max_by_key, so a tie still goes to the first file in sort order as it did before;
ties are reachable, since x.guard and x.ruleset share a prefix.

A test file matching no rules file was discarded without a word. That is what a rename leaves behind.
s3_bucket.guard beside tests/s3_tests.yml, left over from when the rules file was s3.guard: test -d
printed only that the rules file had no tests associated, which reads as benign, and exited 0. Renaming the
test file and changing nothing else gives exit 7. Each unmatched file is now named on stderr with its path.

An unchecked expectation was invisible to every structured reporter. An expectation naming a rule the file
does not define is not checked; the plaintext reporter says so on stderr and json, yaml and junit said
nothing. A test file whose every expectation names a nonexistent rule produced
<testsuites tests="0" failures="0" errors="0"> and exited 0, so a pipeline gating on that report had nothing
to gate on. Reported now as a skip in a field of its own, unchecked_expectations, not folded into
skipped_rules -- those are opposite directions of one mismatch, rules with no expectation against
expectations with no rule, and a consumer reading one array could not tell which had happened. A skip and not a
failure, because escalating it changes the exit code of a shipped CLI for every user with a typo in a test
file, and that is a maintainer's call; making the fact visible is not. The field is omitted when empty, so
every report over a suite with nothing unchecked is byte for byte what it was.

Run against the AWS rule registry at 7f7340c, unmodified, the two diagnostics name three test files that
have never run and eleven expectation names that answer to no rule. Two of those three files are the only test
file their rules file has, so s3_bucket_default_lock_enabled and cloudfront_accesslogs_enabled have no test
coverage at all: one filename drops a trailing d, the other begins with a space. Both suites pass once
wired up. Those are fixed in that repository rather than here.

Merging alongside #719

Both PRs merge into origin/main cleanly on their own. Only the combination conflicts, and no single
resolution direction is correct for it
— of the five conflicted paths, three want this branch's side, one
wants #719's, and two want a union. One of them is dangerous to get wrong and the damage is silent.

Measured by trial merge into origin/main at 3e265bb, with rerere disabled so a recorded resolution
could not report a false clean, and judged by the merge output text rather than by the unmerged-path list.

path this branch's side #719's side resolve to
guard/src/rules/parser.rs the 87-line extract_message grammar the original unbounded find_substring(">>") this branch
guard/src/rules/path_value.rs 288 lines of QueryResolver and its two select functions deleted #719
guard/src/rules/mod.rs the add_merged_capture_key trait method deleted this branch, plus #719's own deletion
guard/src/commands/reporters/validate/common.rs an import list including FileReport and Messages the same list without them this branch (its superset)
guard/src/commands/reporters/test/structured.rs has_unchecked_expectations and number_of_unchecked_expectations build_junit_test_cases with an elided lifetime both

parser.rs is the one that matters. #719 branched before any of the three attempts at extract_message
and its Apply rustfmt commit reformatted the signature, which is what makes it conflict rather than merge.
Its side is the original unbounded search. Resolving that hunk in #719's favour reinstates the defect where one
forgotten >> consumes every following rule as message text and the run reports PASS at exit 0 — the headline
finding of this PR, and silent when it recurs.

path_value.rs is the mirror image: this branch carries that engine only because it does not delete it, and
deleting it is #719's entire purpose. It does not touch the duplicate-key detection added here, which sits
earlier in the file, well outside the conflict region.

The order matters and only one order is dangerous. If #719 merges first, this branch rebases onto it and
the resolution happens here, so no trap reaches anyone. If this branch merges first, the conflict lands in
#719 and the parser.rs hunk is the one that must not be resolved the obvious way.

This table is re-derived rather than carried forward: it named two conflicted paths when this branch was 27
commits shorter, and both the count and the resolution directions have changed since. Re-derive it again if
either branch moves.

A rule could not be composed out of other rules

rule MAIN { H_A H_B } failed whenever a rule it referenced was not applicable. Two helper rules each
guarded by a when on their own resource type, and a template carrying one of those types but not the other,
gave H_A PASS, H_B SKIP and MAIN FAIL at exit 19 — reporting a violation on a template that violates
nothing. A template carrying neither type failed as well.

eval_guard_named_clause claimed every assertion SKIP as FAIL, in both polarities, which also made the arm
below it unreachable for assertions. eval_parameterized_rule_call carried the identical line for r(...),
with a comment claiming to mirror the first; both are fixed together and one test asserts both, so a change to
one cannot pass on the other's coverage.

This is why decomposing a ruleset over disjoint resource types has not been done: neither workaround holds. A
conjunction of references fails whenever any type is absent. A disjunction returns PASS when any one helper
passes, so a template with a violating IAM Role beside a clean DynamoDB table passes a rule that should fail
it — a false negative in a compliance rule. The AWS rule registry's existing helper idiom composes with OR
only because its helpers are alternative paths to one conclusion, and that does not transfer.

A negated reference is included: rule deny when Resources.*.Type exists { not inner } failed on a template
with one S3 bucket and no KMS key. It now reports the rule did not apply because it referenced rule [inner], which did not apply to this input. A negated reference used as a when gate is deliberately unchanged,
because a gate that closes silently disables the rule it guards; the registry's one negated reference is a
gate, and that decision is what protects it.

Exit codes said the tool broke when the rules file was wrong

Two paths reported an authoring mistake as exit 255, the internal-failure code, where 5 means rules-file
error:

  • parse-tree on a rules file it cannot parse — validate said 5 for the same file, so two subcommands
    disagreed about whose fault it was.
  • validate on a rules file referencing a variable no let declares. Forgetting a let is a common mistake,
    and the caller was told the tool had failed. The undeclared name is now reported with Could not resolve variable by name <name> across scopes, so it can be acted on.

The reason a 255 survived here is a test that passed for the wrong reason. guard/tests/parse_tree.rs had two
cases sharing one expected output that is an I/O error. One points at dne.guard, absent on purpose. The
other pointed at validate/rules-dir/malformed-rule.guard while the file lives at
validate/malformed-rule.guard — one directory up. So it failed at File::open, matched the shared I/O
string, and passed green. parse-tree's parse-error exit code therefore had no coverage at all. The fixture,
never having been run, also turned out not to contain a syntax error: it is an undeclared-variable file, which
is how the second defect above came to light. The repository had no genuinely malformed rules fixture; there
is one now, the cases assert what their names claim, and the exit codes are documented, which they were not.

A test expectation could name a rule that does not exist

cfn-guard test printed No rule named X is in this file, so its expectation was not checked and exited
0. An expectation naming a rule that is not there asserts nothing, so a rule renamed without its test file
being updated leaves a test that silently stops testing while the suite stays green.

It is now exit 1 — TEST_ERROR_STATUS_CODE, not TEST_FAILURE_STATUS_CODE, following the line this
repository already draws at guard/tests/test_command.rs: "an expectation that could not be evaluated is a
different answer from an expectation that was not met." All four output formats report it; junit uses its
<error> element, which consumers treat as red exactly as they treat failures.

The question that decided whether a blanket failure is right is whether an expectation for a rule in a
sibling rules file can legitimately be checked in directory mode. It cannot: OrderedTestDirectory::from
reduces the candidate rules files with min_by_key, which yields exactly one claimant, so a test file is
never evaluated against a second rules file. A directory whose test files are named to cover several rules
files at once will now go red, and it was never checking what it appeared to.

This has a consequence outside this repository, measured rather than assumed.
aws-cloudformation/aws-guard-rules-registry has 30 such expectations in
rules/aws/aws_cloudformation/tests/cfn_no_explicit_resource_names_tests.yml, across 11 distinct names, in a
suite that reports clean. Its .github/workflows/{ci,build,publish}.yml each install cfn-guard unpinned
from main and run cfn-guard test -d ./rules/, so when this change is released that repository's CI and
its publish pipeline
go red until those expectations are dealt with.
aws-guard-rules-registry#286
removes them and is green under every cfn-guard version, released or not, so landing it first leaves no red
window in either repository.

A regular expression's own diagnostic was built and thrown away

Both failure returns in parse_regex_inner were nom::Err::Error, which alt treats as recoverable, so the
fancy_regex message was constructed and discarded and the next value production's complaint was reported
instead. == /a(/ exited 5 saying expecting either a property access "engine.core" or value like "string",
with no mention of the parenthesis. It now says Could not parse regular expression: Parsing error at position 2: Opening parenthesis without closing parenthesis. The file's own convention two functions above already
required Failure here and cross-references parse_range; two sites followed it and these did not. Two
existing tests pinned the Error variant by assert_eq!, and moving those assertions is the point of the
change rather than collateral damage.

Two changes that alter what a rules file means

Both are deliberate, both are measured, and neither is a bug fix you can take without noticing it.

A string or regex literal ending in a backslash. parse_string_inner and parse_regex_inner each decided
whether a delimiter was escaped by testing whether the text in front of it ended with \, which cannot tell
\ from \\. When it guessed wrong the scanner resumed past the closing delimiter, so the next quote or
slash anywhere in the file became the terminator — an apostrophe in a comment was enough — and every clause
and rule in between was absorbed into a string value, unevaluated and unreported, at exit 0.

Fixing it means a backslash consumes the character after it, whichever character that is. That is the rule
every language with a backslash escape uses, and it makes a backslash's meaning independent of its position.
The consequences: in a string "a\\b" is now one backslash rather than two, and in a regex \\ closes
itself so a following / ends the literal. There is no reading in which \\/ both terminates in /^x\\/
and does not terminate in [A-Za-z0-9\\/+=] — the swallow and the fixture are one construct, so one of them
has to move.

Measured blast radius: \\ inside a string literal, a backslash immediately before a quote, and \\/
inside a regex each occur zero times across all 313 .guard files and 435 KB in this repository and the
AWS rule registry. The two exceptions are the two copies of advanced_regex_negative_lookbehind_rule.guard
here, whose \\/ is rewritten to \/ — the spelling that always meant what the file intended. A rules
file in the wild using \\/ in a regex now fails loudly at parse rather than misreading silently.

Mixed range bounds, r[0,20.5], are now accepted — and that commit is separable. It is not a defect fix
and can be dropped without touching anything else. docs/CLAUSES.md promises that integer and float
"compare against each other as numbers. That includes range membership", but its examples are r[5.0, 100.0]
and r[5, 100] — homogeneous bounds with a mixed value. Mixed bounds are not promised, and RangeType
holds one type, so refusing them was a representable-shape limitation rather than a defect. The case for
accepting them is that Size in r[1,2] holds for a Size of 1.5 while Size in r[0,20.5] did not parse at
all, so the gate was narrower than the thing it gated; the integer bound widens to float and is refused if
widening would move it. Accepting more input invalidates no existing file. But it changes the accepted grammar
of a public DSL, which is a maintainer's call, so it is its own commit.

Verification

cargo test --release --no-fail-fast: 1489 pass, 0 fail, 2 ignored across 16 targets, from 1379 at the
branch point. cargo clippy --release --all-targets -- -D warnings and cargo fmt --all -- --check both
clean.

The whole blast radius across both repositories, and every item in it is intended:

  • Parse trees. Every .guard file parsed with the branch point's binary and this one, compared on exit
    code and on the sha of the full tree. 210/210 in the AWS rule registry identical. 105 of 108 here identical;
    the three that differ are the deliberately-unparsable fixtures going 255 to 5, with byte-identical tree
    output — the exit-code fix, not a parse change.
  • The registry's own suite. All 44 directories with a tests/ subdirectory, run as
    cfn-guard test --dir at clean upstream, before and after. One changed: rules/aws/aws_cloudformation,
    0 to 1, stdout unchanged. That is the ghost-expectation fix firing on the 30 dead expectations described
    above, and nothing else in the registry moves.
  • Snapshots, not the working tree. Both sweeps read git archive snapshots at explicit refs rather than a
    live checkout, after two measurements in this work turned out to be reading a tree that had moved
    underneath them.
  • A 440-cell oracle matrix over operator x operand-shape x polarity x position, whose expectations come from
    the language semantics rather than from the binary.
  • A 1,626-pair sweep for the rule-reference change specifically — 193 registry cfn-guard test pairs plus
    1,433 validate pairs — byte-identical, with sensitivity proved rather than assumed by showing a fixture in
    the same shape does move across the two binaries.

A caveat on all of it, worth more than the numbers. These are regression instruments. Every one of them
reported zero changes for most of the parser fixes here — correctly, because no fixture contains a forgotten
>>, or a string ending in a backslash, or a transposed range. A differential answers "did any of these
inputs change answer", not "is there an input that gets a wrong answer". Every defect in this branch was
found by reading what a function commits to and then constructing the input that violates it, and each fix
carries a test built the same way.

Two flavours of empty selection answered differently

rule H { Resources.*[ Type == 'AWS::DynamoDB::Table' ].Properties.TableName not exists }
document before after
{} — no Resources key at all PASS SKIP
{"Resources":{}} PASS SKIP
one S3 bucket, no DynamoDB table SKIP SKIP
one clean DynamoDB table PASS PASS
one violating DynamoDB table FAIL FAIL

Rows 1 and 2 are the same logical situation as row 3 — an empty selection with not exists over it — and
answered PASS where row 3 answered SKIP. A rule about DynamoDB tables reported compliance for a document
containing nothing, and correctly reported itself inapplicable for a document containing one unrelated resource.
Across sixteen operator spellings twelve disagreed, and the disagreement was not uniformly permissive: for
exists, !empty, == and in the empty-document flavour answered FAIL where the other answered SKIP. The
highest-stakes row was a when condition, where a not exists gate opened on one flavour and closed on the
other — changing whether the guarded body ran at all rather than only what was reported.

Two fixes were implemented and rejected before this one, both caught by measurement. Treating any
all-unresolved result as an empty selection turns Resources.*.Properties.BucketEncryption exists from FAIL into
SKIP — "this bucket has no encryption configured" is the entire point of a compliance rule, so that reading gives
up the tool's main job. Keying off "an expansion with query parts after it" produced every target row correctly
and then failed four existing tests that encode requirements rather than the defect: an IAM role carrying no
Tags must fail Properties.Tags[*].Value == /.../, and some Tags[*].Key == /PROD/ over Tags: [] must fail.
Those four tests are unchanged.

The predicate that satisfies all of it: an empty expansion yields an empty selection only when a filter is
still pending at or after the point the collection came up empty
. A filter is how the language says "the subset
of things this rule is about", so a filter that has not yet run over an empty input means the rule has no
subjects. A bare wildcard is a projection over a property of a subject already chosen, so an empty projection is
an answer about that subject. Searching from the current index rather than from zero is what keeps
Resources.*[ Type == 'X' ].Properties.Tags[*].Key == /PROD/ failing on an empty tag list — that filter has
already run, the resource was selected, and its empty Tags belongs to it.

This routes the empty-collection case into the SKIP path that already exists for filters, which
eval.rs's own comment describes as happening "when the query has filters in them". QueryPart::Index is
excluded deliberately: Items[0] names one element rather than enumerating a set.

Blast radius: all 44 directories of the AWS rule registry run as cfn-guard test --dir, and every one of its
210 rules files run against three empty-ish documents — 630 verdict pairs, zero changes. Every registry rule
is gated by when %var !empty, so none takes the path this changes; the defect was reachable by hand-written
ungated rules.

Known limitations

Reviewing this branch found more than reading it did

Later passes over this branch's own commits turned up four things that no amount of reading the diff would
have reached, because each only exists when a documented command is actually run. They are worth listing
separately because three of them are in the tooling around the change rather than in the change.

cargo test --all aborted on this branch. Fifteen of the 34 tests that parse at or near
MAX_NESTING_DEPTH exceeded libtest's 2 MB thread stack in the default debug profile — SIGABRT, cargo exit
101 — while --release passed. pr.yml runs cargo test --verbose, which is debug, on ubuntu, macos and
windows, so this was red on three platforms. The deep parses now run on an explicitly sized thread, so they
no longer depend on the harness's default. What hid it is worth naming: MAX_NESTING_DEPTH's doc comment
justified the bound with "about 2.3x to spare", measured on an optimized build and written without saying so.
Unoptimized, the same ladder tops out below the bound. Every stack figure in that comment now states its
profile, and the argument for 128 rests on the corpus instead, because a corpus fact survives a change of
profile.

cfn-guard test exited 0 on a rules file it could not parse, in json, yaml and junit, while
printing errors="1" in the same run. A CI job gating on the exit code saw success on a broken ruleset,
which is the worst failure direction available for a policy tool. single-line-summary already exited 1 on
the same bytes, so the two format families disagreed. All four now route through the same
TestResult::get_exit_code() rather than assigning codes by hand at each site.

The four output formats disagreed about content and about codes in three more places, found by
enumerating the grid rather than by fixing the reported case: a stale-rule-name diagnostic was dropped in
single-line-summary when the same case also carried an unparseable expectation string, and the
directory path answered 7 where three formats and the single-file path answered 1 for identical input. There
are now tests asserting that every format agrees over one rules file and over a directory — the invariant,
not an instance. Both of the earlier fixes here had been to an instance, and one of them created the same
defect pointing the other way.

The Windows registry CI step decided pass/fail from stdout. if (<command>) in PowerShell tests the
command's output for truthiness, not its exit code, and that command prints thousands of lines — so the step
logged "have passed" for runs that had exited non-zero, and would have failed a successful run that printed
nothing. Its bash twin uses if <command>; then, which does test exit status, so the two legs of one check
were checking different things. Both now test $LASTEXITCODE. The same expression appeared in the
parse-tree loop, where it was correct only by luck.

Four doc comments also stated numbers that could not be reproduced, which matters here because a false
comment in this area misdirected several of these investigations. The corpus depth maximum was 6 and is 7,
reached by two files rather than four; the loader's two corpus figures were each high by one, and one of them
named "the deepest CloudFormation template" in a corpus that contains no template files. A ratio was still
derived from a threshold that had already been corrected. And a call-graph component count was removed rather
than corrected: the figure depends on how a parser passed to a higher-order combinator is attributed, four
defensible models give four different answers, and the sentence named none — so no reader could check it. Two
user-facing refusal messages that quoted corpus statistics now make their point qualitatively, because a
message asserting a maximum over a repository whose release cadence this one does not control is wrong again
on someone else's schedule.

Three questions are left open on purpose, each measured and recorded rather than settled by accident: a key
filter after [*] versus .*; what a filter applied to an already-indexed value (Rules[0][ ... ]) should
mean; and ::MODULE stripping in a type-block name. Each needs a decision about what the language means, and
guessing one is worse than leaving it visible.

@awsmadi
awsmadi force-pushed the pr/filters-captures-and-reporter-silence branch from 0316b30 to e220608 Compare August 25, 2026 21:04
awsmadi added a commit to awsmadi/cloudformation-guard that referenced this pull request Aug 25, 2026
aws-cloudformation#727 gained thirteen commits while this branch was being reviewed, and the two lines both changed
`guard/src/rules/eval_tests.rs` and `guard/src/rules/eval.rs`. Merged rather than rebased: rebasing this
branch's commits one at a time through a test file that both lines append to conflicts once per commit, and
each resolution moves code that the next patch expects to find in place.

Two resolutions were needed and neither is mechanical.

`eval_tests.rs` keeps this branch's layout, with aws-cloudformation#727's nine added items re-applied as one contiguous slice.
Extracting them item by item truncated an attribute block, because a `#[case::...]` attribute wraps across
source lines and its continuation matches no attribute pattern, which dropped the `#[rstest::rstest]` above it
and left two `#[case]` parameters with no macro to consume them. The whole-slice form carries the attributes
with the items.

`eval.rs` had aws-cloudformation#727's two new arms answering `Ok(Status::FAIL)` in functions this branch changed to return
`Result<Outcome>`. They now answer `Outcome::Unevaluatable` role-free, which is this branch's vocabulary for
the same intent: the answer is a value and the consumer applies the role, rather than the producer splitting on
`role.is_strict()` as the pre-`Outcome` version did.

Three of aws-cloudformation#727's tests fail on this merge and are addressed in the commit that follows, not here, so that the
reconciliation is visible as its own change rather than buried in a merge.
awsmadi added a commit to awsmadi/cloudformation-guard that referenced this pull request Aug 25, 2026
…data cannot supply

The merge took aws-cloudformation#727's production change together with the pre-aws-cloudformation#727 version of the test that change
was written to replace, so three cases of a_function_argument_that_selects_nothing_does_not_panic
failed on a tree where each side passed alone.

9c7f5a5 reclassified an unsuppliable scalar function argument from ParseError to IncompatibleError.
ParseError is the class the evaluator reserves for a malformed rules file and is_unevaluatable does
not recognise it, so a -1 in a template field that some rule feeds to substring aborted the run at
255 rather than failing the clause at 19. That reclassification is present at b2bb012. The test read
its expectation off the error out of eval_rules_file and asserted the message named the second or
third argument, so with the error gone it read the empty string and the three cases failed with
"must name the second argument rather than abort; got \"\"". 9c7f5a5 had already rewritten the test
in the same commit to assert Status::FAIL through rule_status_in, and said so in its message. The
merge kept the merge base's test body character for character, so that rewrite was the only part of
9c7f5a5 that did not land.

The test is the side that was wrong. What it asserted, that eval_rules_file returns an error naming
the argument, is the behaviour 9c7f5a5 deliberately removed, so keeping it would mean reverting the
fix to satisfy a test written against the defect. aws-cloudformation#727's replacement is also the stronger assertion:
FAIL covers both the panic the test was written for and the abort that replaced it, and here it pins
the one thing the argument's role decides, because to_status sends Outcome::Unevaluatable to FAIL for
an assertion and to SKIP for a gate. The doc comment now says that instead of aws-cloudformation#727's wording about
there being no error left to read.

The Outcome translation of aws-cloudformation#727's two right-hand-side arms in eval_guard_access_clause is not
implicated in these failures and is right as it stands. Those arms come from 7dcfc1b rather than
9c7f5a5, and a let-bound function argument does not reach them. Outcome::Unevaluatable is what the
clause's own query arm further down already answers for the same error, role-free, with a comment
explaining that the split on role.is_strict() moved to to_status and closes_gate at the consumer.
Two comments are corrected rather than left standing: the sentence retained from aws-cloudformation#727 said a gate
still keeps the error and named the left-hand side as its precedent, which the merge made false in
both halves, and the per-arm repetition of the reasoning is dropped now that the block comment above
both arms carries it.

Verified: 1525 tests pass, 0 fail, 2 ignored across 16 test binaries, from 1522 passing and 3
failing. cargo build --release, clippy --release --all-targets -D warnings, fmt --all --check and
typos are clean. All 193 convention-paired rule and test pairs in aws-guard-rules-registry at 6aca96e
exit 0. aws-cloudformation#727's reproductions behave as it claimed: a substring bound the data cannot supply and a
join separator likewise both move 255 to 19, a sibling rule in the same file still reports its own
FAIL and still names the security group open to 0.0.0.0/0 as the one non-compliant resource, and the
controls where the argument is supplied are unchanged, at 19 with a violating sibling and 0 without.

Across 4,536 rule-by-data fixture pairs under guard/resources/validate against the pinned r720c
binary there are 37 exit-code differences and 54 text differences, none of them this commit's: the
change to eval.rs is comments only and eval_tests.rs sits behind cfg(test), so the release binary is
byte-for-byte equivalent in behaviour to b2bb012. All 37 exit-code differences are attributable to a
aws-cloudformation#727 commit, moving 255 to 19 or 0 to 19 in the direction that commit intended: bad_function_arguments
and join_with_message to 9c7f5a5, count_empty_collection and count_unresolved to c9ff4d6,
embedded_json_the_parser_rejects to b90613b, numbers_that_do_not_fit to 9cde8e5 and
regex_replace_no_match to 145e8ad. Twelve of the 17 text-only differences are c9ff4d6 and 9cde8e5
reporting a count or a conversion as unevaluatable where the pinned binary answered zero or
saturated to i64::MAX, at the same exit code either way. The remaining five are the reason suffix
that 27a86f5 appends to a memoised rule answer, which means r720c predates 27a86f5 and is not the
merge's first parent; it matches 4613118, one commit earlier on the same branch.
awsmadi added a commit to awsmadi/cloudformation-guard that referenced this pull request Aug 26, 2026
…erence-to-inapplicable question toward aws-cloudformation#727

Two conflicted paths, and eval.rs was not a textual conflict: the two branches
held opposite, separately-argued positions on what a reference to a rule that
did not apply means.

This branch mapped it onto a violation for assertions --
`(Outcome::NotApplicable, _) if role.is_strict() => Outcome::Violated` -- on the
ground that "a rule body asserting `r(...)` claims that `r` holds, and a rule
that never ran is not evidence that it does", and flagged the resulting change
from main as deliberate. That reasoning is true and the conclusion drawn from it
is not: the answer to "no evidence" is no verdict, not a violation.

Resolved toward aws-cloudformation#727 for two reasons.

It produced a false positive. With two helper rules each gated on their own
resource type, a template carrying one clean instance of the first type and
none of the second reported FAIL at exit 19, and so did a template carrying
neither type -- a violation asserted about a resource type the template does
not contain. The row that matters for correctness, a violating instance beside a
clean sibling, still FAILs, so this is not a slide into disjunction.

It contradicted this PR's own algebra. `Outcome::and` has identity
`NotApplicable` and absorbing `Violated`, so one level down an inapplicable
clause conjoined with a satisfied one yields satisfied. Mapping that same
`NotApplicable` onto the absorbing element at the reference site left the type
saying "not applicable is neutral" while this arm said "not applicable is
fatal", for one input. Whichever way the question is answered, the reference
site and `Outcome::and` have to answer it the same way, and only one answer is
expressible without carving an exception out of the type.

Both sites are resolved, `eval_guard_named_clause` and
`eval_parameterized_rule_call`, and both matches stay exhaustive over the enum
rather than ending in `_`: that catch-all had been silently covering both a
failing dependent and a negated gate on an inapplicable one. The negated `when`
gate is unchanged -- a gate that closes silently disables the rule it guards,
and the AWS rule registry's one negated reference is a gate. The comments
arguing for the old behaviour are removed rather than left beside code that now
does the opposite.

eval_tests.rs was additive on both sides; all twelve test functions are kept.
awsmadi added a commit to awsmadi/cloudformation-guard that referenced this pull request Aug 26, 2026
aws-cloudformation#727 gained one commit: an empty expansion yields an empty selection only when a
filter is still pending at or after the point the collection came up empty, so
the two flavours of empty selection stop disagreeing.

Merged clean textually. Verified behaviourally rather than on that basis, because
the previous merge from this branch was also textually clean and had aws-cloudformation#727's
Ok/Status::FAIL arms meeting this branch's Result/Outcome return type, which git
cannot see.
awsmadi added 23 commits August 26, 2026 15:12
`f64::from_str` signals overflow by returning an infinity and underflow by
returning zero without saying so. The `is_finite` gate caught the first and not
the second, so `1e400` fell through to a string while `1e-400` became
`Float(0.0)`: a value the document writes as a small positive number answered
`== 0` with a PASS and `> 0` with a FAIL, at exit 0, with nothing on either
channel. One rule disagreed with itself at the two ends of the same exponent
range.

It also made a comment in `rules::parser` false. That comment explains why the
rules parser rejects both saturating and underflowing float literals while the
document side does not, on the grounds that "there, `loader.rs` retypes the scalar
to a string, which leaves every comparison against it incomparable and therefore
failing closed". That was true of the overflow half only. Both halves now do it.

The discriminator is the mantissa, not the whole literal. A zero result is genuine
when every significant digit is zero and an underflow when one of them is not, and
the exponent has to be excluded from the test or `0e400` -- a real zero -- would be
read as an underflow on account of the `4`. Eight zero spellings are pinned as
controls for that, including `0e400` and `0.0e-400`.

A small float that is still representable is unaffected, which
`a_small_float_that_is_still_representable_stays_a_float` pins with a subnormal:
without it the change is also satisfied by refusing every negative exponent.

User-visible change: a float literal too small to represent is the string it was
written as, so `is_float` answers false and a comparison against a number reports
the type mismatch instead of answering as though the author had written zero.

Not fixed on the serde-backed conversion, and the reason is not effort.
`serde_yaml::Number` is `enum N { PosInt(u64), NegInt(i64), Float(f64) }` and
retains no source text -- verified against the pinned crate source, 0.9.34 -- so an
underflowed `1e-400` arrives there as `Float(0.0)`, indistinguishable from a
literal `0`. The divergence is recorded in
`both_loaders_resolve_the_same_document_to_the_same_value` with that reason.

Blast radius: no data fixture in the rules registry snapshot or this repository
contains a float literal outside f64's range. Whole suite green with no fixture
regenerated.
…, in the serde conversion

Two defects in one `serde_yaml::Value::Number` arm, both reached from
`PathAwareValue::try_from(serde_yaml::Value)` -- which is what the `test`
subcommand and the public `run_checks` read documents with.

The sign inversion first, because it is the worse of the two. The code read
`num.as_u64().unwrap() as i64` under a comment saying "Yes we are losing precision
here. TODO fix this". It was not losing precision: `as i64` reinterprets the bit
pattern, so the sign flipped. `u64::MAX` read as exactly -1 and `i64::MAX + 1` as
exactly `i64::MIN`. Every numeric guard in the language inverts for such a value --
measured, not inferred: `A < 0` PASSed, `A == -1` PASSed, and `MaxSize <= 1000`
PASSed for an input of 18446744073709551615, all at exit 0 with nothing on either
channel. `compare_values` was blameless; it was handed the wrong integer.

The digits are kept instead. Three alternatives, and what each costs:

  - **Error on a u64 above `i64::MAX`.** Smallest, and it refuses rather than lying,
    but it refuses the whole *document*: one oversized number and every other rule
    in the file stops being checked. Failing closed on the one value is less than
    that.
  - **Widen `Value` with an unsigned variant.** Correct in principle, and much
    larger than it looks. `Value` is matched exhaustively in the rules parser, both
    conversions, `Display` and `Hash`; and for the exactness to reach a comparison
    at all, `PathAwareValue::Int` needs the same widening, which lands in every
    `compare_*` function -- another change's territory. It would put an exact value
    in a type nothing yet compares against.
  - **Store it as `f64`.** Trades a sign error for a precision error rather than
    removing one: `u64::MAX` becomes 18446744073709552000, so `== 18446744073709551615`
    fails and `> 18446744073709551000` passes. Still a wrong answer, only quieter.

Keeping the digits is exact -- `u64::to_string` invents nothing -- it makes a
comparison against a number refuse rather than answer from a number the input does
not contain, and it is the answer the libyaml loader already gives an integer this
wide. That last point is why this and the loader's out-of-i64 handling were decided
together: they are the same defect on two loaders, and they now agree.

The float half is an `Eq` violation that was real rather than theoretical.
`PathAwareValue` asserts `Eq` and `Float(NaN)` is not equal to itself, so
`A == A` reported FAIL through this conversion on a document that PASSed through the
libyaml one. `.inf` was worse than inert: `A > 9223372036854775807` PASSed under
`test` where the same document refused under `validate`. The libyaml loader has
carried the finiteness gate all along, with a comment stating the invariant; this
conversion never had it.

Non-finite values become YAML's own spelling -- `.nan`, `.inf`, `-.inf` -- rather
than `f64::to_string`'s "NaN" and "inf", which no YAML document writes. `serde_yaml`
resolves a float only from those three spellings, bare `NaN` and `inf` staying
strings in both loaders, so the canonical spelling makes the two agree exactly on
every input that can reach the function rather than approximately.

User-visible change, on `guard test` and `run_checks` only: an integer above
`i64::MAX` and a non-finite float are strings, so `is_int`/`is_float` answer false
and comparisons against numbers report the type mismatch.

Both divergences were on the documented exclusion list of
`both_loaders_resolve_the_same_document_to_the_same_value`; they are now in its
document, which covers 34 spellings. `0b101` and the underflowing float remain
excluded, each with its reason.
…e it

Any key that was not a `String` was refused, which refused templates
CloudFormation accepts. A template is converted to JSON before it is deployed and
JSON has no key but a string, so

    Mappings:
      AccountToEnv:
        123456789012:
          Env: prod

-- an account id written the way a person writes one -- aborted the run at exit
255, while the same content written in JSON, `{"123456789012": {...}}`, already
loaded. `path_value::list_index_of`'s doc comment presents this as fixed and names
account ids, ports and status codes as the cases; the retrieval half was fixed, and
the template half was unreachable, because a document writing the key the natural
way never got as far as retrieval. It does now.

**This reverses the reasoning I recorded two commits ago**, when the diagnostic for
this refusal was improved. That reasoning was that rendering a resolved value
"invents a name the document does not contain", since an `Int` from `0x1F` renders
as "31". The premise was wrong. CloudFormation resolves `0x1F` as 31 by the same
YAML 1.2 core schema this loader implements and then stringifies it for JSON, so
"31" is exactly the key CloudFormation sees. Rendering the resolved value models the
deployment; rendering the source text would not.

Checked against a YAML-to-JSON round trip rather than asserted -- `yaml.safe_load`
then `json.dumps` gives `{"80":…, "1.0":…, "1.5":…, "31":…}` for the same keys, and
the loader now produces the same five names. A whole float is formatted with its
fractional part, so `1.0` is "1.0" and not Rust's "1", because "1.0" is what that
round trip produces.

`Null` is deliberately not in the set. There is no text the two conventions agree on
-- Python's round trip gives "null", JSON has no such key -- and a document writing
`~:` is far more likely to have lost a key than to want one named after nothing, so
the refusal is the more useful answer. Container and `BadValue` keys stay refused for
the stronger reason that they have no scalar text at all.

Most of this finding's blast radius had already gone: of the ten bool-shaped keys
that aborted the run, `y n Y N on off yes no` were fixed by moving to the YAML 1.2
core boolean set, leaving `True` and `Null`. `True` is fixed here.

Two tests asserted the old behaviour and are changed.
`a_non_string_key_is_refused_with_the_key_named` keeps the keys that have no text and
loses the integer, float and boolean cases to a new
`a_scalar_key_becomes_the_text_cloudformation_would_give_it`, which pins the text each
one produces. `test_graceful_handling_when_yaml_file_has_non_string_type_key` loses
`1: foo` and `1.0: foo` for the same reason, keeping the null key, the sequence key,
and the document that does not parse.

One case in the first test was mine and wrong: `": x"` is not valid YAML -- libyaml
refuses it while parsing a block mapping, so it never reaches the key check. The only
empty-key spelling YAML accepts is the explicit `?` indicator, and it resolves to a
null key, which is the case already covered.
The refusal said "more than one", which leaves the reader to find out whether they
have two documents or twenty before they can act on it. It now says the number.

Counting means draining the rest of the stream, and the rest of the stream may not
parse -- a file whose *later* document is not YAML is one of the shapes this refusal
exists for. So the count degrades to a lower bound, "at least 2", when libyaml stops
early. That is better than either abandoning the count or letting the parse failure
propagate, which would replace a message about document structure with a syntax error
from further down a file the user is about to split anyway.
…lure

A data file cfn-guard could not read reached `main`'s catch-all, which prints
"Error occurred" and exits -1 -- the code `guard/tests/utils.rs` names
`INTERNAL_FAILURE`. So a template with an unquoted `~` key, an empty data file, a
file that is not YAML, and a multi-document stream all told the user cfn-guard had
broken, when each is their own input.

`ERROR_STATUS_CODE` is 5, which this repository already gives a ruleset it cannot
use. Two commits before this branch moved rules-file mistakes to it for exactly this
reason, and their wording is the argument here too: 5 is not 19, so a CI gate still
tells a broken input from a violation, and unlike -1 it does not additionally claim
the tool is at fault. `guard-ffi`'s error table had already reached the same
judgement independently, mapping `ParseError` to its code 5.

`ParseError` is the discriminator, and it is safe to classify on because every one
of its producers is about the user's input: the rules parser and its let-cycle
checks, the libyaml loader, the test-spec readers, the payload deserializer, the
data-file reader. Enumerated with `git grep`, which is the only search tool immune
to the NUL bytes in `parser_tests.rs`. None of the twenty-odd sites is an internal
failure, so there is no arm this over-catches.

A *missing* file keeps -1, deliberately. `File::open` and `validate_path` return
`IoError` and `FileNotFoundError`, not `ParseError`, so they are untouched -- the
same line the parse-tree commit drew when it moved that command's parse error and
left its missing-file error alone, on the grounds that validate, test and parse-tree
already agree on -1 for a path that does not exist. Both `dne` cases in the
status-code table still assert it.

The classification is a thin wrapper around the existing body rather than a
restructuring of it: `execute` now matches on the result of `evaluate`, which is the
former `execute` unchanged. Reported by the command rather than left to `main`,
because once this function has decided the failure is not internal, "Error occurred"
no longer describes it -- so stderr loses that prefix, which
`test_a_data_file_with_no_document_is_reported_as_empty` asserts.

User-visible change: `validate` exits 5 where it exited 255 for a data file whose
content cannot be read, and the stderr line loses its "Error occurred" prefix.
Verified unchanged: a compliant file still exits 0, a violation still exits 19, and a
path that does not exist still exits 255 with the same message.

Twelve test cases asserted the old code and are changed, with the reasoning at each.
…it reverted

`failing_complex_rule.out` and `failing_join_show_summary_all.out` were both changed
by "Report the operand a query-versus-query equality actually failed on" and then
again by "Count data-file lines and columns from one". The second regenerated twelve
fixtures from the binary, and that binary did not contain the first -- the two were
written on the same base and neither could see the other. So the committed text
carries the 1-based line and column numbers and, underneath them, the operand
reporting the earlier commit had already removed.

Regenerating from a binary is the right way to update a captured output and the wrong
way to update one a sibling branch has also touched: it reinstates whatever that
sibling removed, silently, and the result still looks internally consistent.

Both files now come from the combined binary. Each differs from the committed version
in exactly the two lines the operand commit had changed, and in no others, so the
line numbers and excerpt windows the numbering commit established are untouched.

What the restored text says, and why it is the true statement:

- `failing_complex_rule.out`. `%expected == %replaced` with `%expected` the literal
  "random_str" and `%replaced` a regex_replace of the Arn. `ComparedWith` is
  `["random_str"]` and the reason names `Path=[L:0,C:0]`, because a rule literal is
  built with `Path::root()` and has no position in any file. The committed text
  printed the Arn as both `Value` and `ComparedWith` -- a failed equality whose two
  operands it reported as the same string, asserting the Arn was absent from a set
  holding the Arn.

- `failing_join_show_summary_all.out`. `"a,b" == join(%collection, ",")`. The left
  operand in this position is a query for a property named `a,b`, which is why the
  check renders unquoted as `a,b EQUALS join(...)`; the template has no such
  property, so the operand set is empty and the diff of its unmatched values is `[]`.
  `Value` and `PropertyPath` still name the join result, because `QueryIn::from_rhs`
  places the finding on the side a reader can act on. The committed text again
  printed one value as both sides.

Markers checked against the template by counting rather than by trusting the binary.
In `functions/data/template.yaml`, line 10 is `      Arn: arn:aws:...` and its value
begins at column 12; line 15 is `      - a` and the `a` is at column 9. The fixtures
say `L:10,C:12` and `L:15,C:9`. The excerpt beside each marker runs n-2 to n+3 and
each numbered line holds the template's text for that line.

No product code changes. validate: 211 passed 4 failed, to 213 passed 2 failed, with
no other test in the target moving.
…erts its message

`a_payload_that_will_not_parse_says_where_it_came_from` asserted `INTERNAL_FAILURE`.
It was written when that was the only outcome available: `deserialize_payload`'s
`Error::ParseError` escaped `validate` and reached `main`'s catch-all, which prints
"Error occurred" and exits -1. A later commit on a sibling branch classified every
`ParseError` out of `validate` as the caller's input and gave it `ERROR_STATUS_CODE`,
naming the payload deserializer as one of the producers it meant to cover. Neither
commit could see the other, so the code moved and the assertion did not.

The classification is the side that is right, and this case is the one it argues for
most directly. Empty stdin under `--payload` is the CI shape the message was rewritten
for; -1 is the code this repository reserves for cfn-guard breaking, so the old pairing
told an author who forgot a pipe that the tool was at fault. 5 is not 19, so a gate
still tells a broken input from a violation.

The message assertion needed nothing. `deserialize_payload` still names the flag, the
stream and the expected shape, and the reclassification only drops `main`'s prefix:

    Parser Error when parsing `Unable to parse the --payload JSON read from stdin: EOF
    while parsing a value at line 1 column 0. Expected {"rules":[...], "data":[...]}`

which is the same form `test_a_data_file_with_no_document_is_reported_as_empty` pins
for an unreadable data file.

Added an assertion that stderr does not carry "Error occurred". The code and the
message are set in two different places -- the code by `execute`'s classification, the
prefix by whether `main` ever sees the error -- and this pairing is what the two
branches disagreed about, so it is worth holding rather than inferring.

validate: 213 passed 2 failed, to 215 passed 0 failed.
rulegen emitted `""` for a null. That was right against a loader which resolved an
empty YAML node to the empty string, and a sibling commit made an empty node resolve to
null -- deliberately, so that `k:` and `k: ""` stop being indistinguishable. The two
crossed over: `"NULL"` against `""` is `not comparable null, String`, and every rule
generated from a template with an empty `BucketEncryption:` failed the template it was
generated from. Three fixtures under guard/resources do this, and the round-trip test
the rulegen commit added is what named it.

Fixing the null arm alone would have been wrong, because the null arm is not the only
place the two readers disagree. Measured over 36 one-property templates, one per YAML
spelling, before this change:

  template writes            loader reads         rulegen emitted        round-trip
  P: / P: null / P: ~        null                 ""                     fails
  {Inner: null} / [a, null]  null, nested         "", nested             fails
  P: .nan / P: .inf          the text ".nan"      ""                     fails
  P: 1e-400                  the text "1e-400"    0.0                    fails
  P: 0b101                   the text "0b101"     5                      fails
  P: 9223372036854775809     the text             a 19-digit int         does not parse
  <<: {A: 1}                 the merge resolved   a key named "<<"       fails
  !Ref / !GetAtt / any !Foo  {Fn::Foo: ...}       --                     abort, exit 1

Ten emitted a clause the source template does not satisfy. One emitted a file that does
not parse, which the re-parse check then discarded. Five aborted the process at exit 1 --
that is every CloudFormation template using a short-form intrinsic, because serde_yaml
will not deserialise a tagged node into serde_json::Value at all.

**Reconciling the two readers by hand is not possible**, and that is why this reads once
rather than twice. serde_yaml maps `.nan` and an empty node to the same `Value::Null`,
and the loader reads the first as the string ".nan" and the second as null. No rendering
rule over serde_json::Value can be right for both, because the distinction is gone
before rendering starts. `!Ref` is the same shape one level up: one reader has no value
at all where the other has a mapping.

So `generate_rule_map` now loads through `rules::values::read_from` and
`PathAwareValue::try_from` -- the two calls `validate`'s `build_data_file` makes -- and
`value_to_guard` renders a `PathAwareValue`. The value rendered is the value compared,
so a future resolution rule in the loader cannot desynchronise them. `PathAwareValue`
and not the loader's `MarkedValue`, because the conversion between them is where a
mapping's duplicate keys collapse last-write-wins, and a template writing `P` twice must
generate a clause about the value the evaluator will see.

Each rendering was measured by generating the clause and validating it against its own
template. Two need saying:

- a null is `null`, at any depth.
- a whole float keeps its fractional part. `f64::to_string` drops it, and `1e20` then
  comes out as 21 digits with no point, which the rules parser rejects as an integer too
  wide for i64. With the point it round-trips, checked up to 1e300 and down to 1e-300.

Everything the loader keeps as text -- the too-wide integer, `.nan`, `.inf`, `1e-400`,
`0b101`, `0755` -- now renders through the string arm and needs no arm of its own. So do
the intrinsics: `!GetAtt Other.Arn` becomes `{"Fn::GetAtt":["Other","Arn"]}` and matches.
All 36 spellings round-trip now, 35 as a passing clause and one refused with its reason.

Two other changes fall out of this and are not separable from it:

`process::exit(1)` on an unreadable template becomes `ERROR_STATUS_CODE`. Three call
sites exited 1 from inside a library function: a code that collides with
`TEST_ERROR_STATUS_CODE`, that rulegen does not document, and that an in-process caller
cannot survive. `generate_rule_map` returns `Result` and `execute` reports it, which is
the shape `validate` uses for the same class. A template that does not *exist* keeps -1
through the `?` on `read_to_string`, which is the line validate draws too.
`parse_template_and_call_gen` returns `Result<RuleMap>`; it is public but
`#[allow(dead_code)]` and only the unit tests call it.

The round-trip test's candidate filter widens. It read `HashMap<String,
serde_json::Value>` to match what rulegen parsed with, justified as keeping the walk away
from the `process::exit(1)`; both halves of that are now obsolete, and what is left is
that it was the narrower reader -- it could never admit a template using `!Ref`. It now
deserialises only a `Resources` field, ignoring the rest of the root, so an
`AWSTemplateFormatVersion` string does not disqualify a file. The logical ids go through
a HashMap and not a serde_yaml::Value, because a Value refuses a duplicate mapping key
and `duplicate-logical-id-template.yaml` declares one on purpose -- reading it as a Value
silently dropped that template, taking the candidate count from 35 to 34. The final
candidate set is byte-identical to the old one, 35 files, so the widening costs nothing
today and stops the walk from skipping the first intrinsic-using template someone adds.

Two unit tests asserted the old rendering and now assert `null`. The reason each gives is
the one above: the old expectation was correct against the old loader.

Suite: 1806 passed 5 failed, to 1811 passed 0 failed across all 16 targets. Round-trip
over guard/resources: 35 candidates, 28 round-trip, 7 refused at PARSING_ERROR -- four
carry no properties, two carry them only on a resource with no Type, and one is the
boolean-observed-both-ways refusal. clippy --all-targets -D warnings clean, fmt clean.
…er than fatal

The recursive descent in `parser.rs` recurses once per level of nesting and had no bound,
so a deep enough rules file killed the process. Measured on this repository's release
build, `parse-tree --rules` on a file of nested block clauses: 1601 levels parse, 1602
aborts with SIGABRT and "fatal runtime error: stack overflow" on stderr -- reported as 134
by a shell, which is outside the set this tool documents, so a caller checking for 0, 5 or
19 got neither a pass nor a failure it could report. `validate` and `test` die on the same
file, `validate` at a shallower depth than `parse-tree` because it does more work on the
same stack.

Same defect class as the `let` cycle and rule-reference cycle checks, and the same
argument: an authoring mistake that aborted the process instead of being refused.

Nested blocks are not the only recursion that reaches it, and that is why the level is
opened in three places rather than one. Four spellings were measured to abort, at four
different depths:

    nested block clauses    parses 1601, aborts 1602
    nested `when` blocks    parses 2000, aborts 4000
    nested map literals     parses 2000, aborts 4000
    nested list literals    parses 4000, aborts 8000

The last three are coarse -- the ladder was 2000/4000/8000 -- and the exact threshold does
not matter three orders of magnitude below it. The four share no single function: `block`
covers the first two, and list and map literals do not pass through it. A bound in `block`
alone would have left the value literals fatal.

A fifth recursion is deliberately not bounded. A query filter (`q[ q[ ... ] ]`) recurses
through `predicate_filter_clauses` and never gets deep enough to abort, because it becomes
unusable first: timed on this build and on the one before it, level 14 takes 0.25 seconds
and each further level doubles it -- 0.48, 0.97, 1.92, 3.85, 7.66, 15.46, 30.67 at level
21, every one exiting 0. It is exponential in the depth, so level 128 would be 2^107 times
30 seconds. A bound at any value that admits real files could never fire on it, and one low
enough to fire would be a workaround for the backtracking rather than a depth bound. That
is a separate defect and it needs a separate fix.

128 is the value `libyaml::loader::MAX_NESTING_DEPTH` already enforces on the other kind of
input this tool reads, and there is no reason the product's answer to "how deeply may input
nest" should differ by file kind. It is far above anything real, measured by the parser
rather than by counting braces -- braces appear in strings, regexes and comments, so a
throwaway build reported the deepest level the guard actually opened per file. Across both
corpora, every `.guard` and `.ruleset` in this repository and in the rules registry
snapshot, 318 files, the deepest is 6 levels; 172 of the 318 reach only 1. And it is far
below every abort above, the nearest at 1602. Nothing between 6 and 128 is a file anyone
writes, and nothing between 128 and 1602 was going to be evaluated anyway.

Where the count lives was decided against two alternatives. Threading a `depth: usize`
through the parser would change the signature of every function on all four recursion paths
-- `clause`, `access`, `cnf_clauses`, `disjunction_clauses`, `parse_value` and everything
between -- and of the `pub(crate)` ones the tests call directly, to carry one integer, and
would break `alt((clause, rule_clause))` where both arms must have one shape. Carrying it in
`Span`'s `extra` looks better because `Span` is `Copy` and a nested call would get depth + 1
for free, until the return path: the remaining span comes back holding the incremented
depth, so every construct would have to restore it, and one missed restore makes the depth
ratchet up monotonically -- a failure that reads exactly like a correct implementation until
a legal file is refused. A thread-local count with an RAII guard is three call sites and no
signature changes; `Drop` is the only decrement, so an `alt` arm that opens a block and then
fails unwinds and leaves the count where it found it; and the count does not vary with which
thread parses, which matters because `cargo test` parses on libtest's threads rather than on
`main` and a limit derived from the stack would admit a different set of files under test
than in the binary.

User-visible change: a rules file nested more than 128 levels deep is refused with a message
naming the construct, its position and its level, at the exit code every other rules-file
error already uses -- 5 under `validate` and `parse-tree`, 1 under `test`. That 1 is not new
here: an ordinary syntax error, a rule reference cycle and a duplicate file-level `let` all
give `test` 1 and `validate` 5 both before and after this change.

Measured after: no 134 on any of the three commands at any depth from 129 to 8000, where
before this change 1650 aborted on all three. The boundary is pinned at 127, 128 and 129 in
each of the four spellings, because an off-by-one there would either refuse a file one level
inside the documented limit or admit one past it, and neither shows up in the deep cases. 500
rules of 4 levels each is the control that says the bound is on nesting and not on how much
of the file is nested. And one test parses a legal at-the-limit file, then four refused ones,
then the same legal file again -- a guard leaking on the failure path shows up in that order
and in no other.

Both corpora sweep clean: 0 changed cells out of 318 files, comparing exit code and both
streams, which is what catches a diagnostic moving as well as a verdict moving.
…, in `RuleRefs`

The doc comment on `trait RuleRefs` said their clause parsers "take access clauses only", so
`rule a { AWS::EC2::Volume { a } }` and `rule a { Resources { a } }` are both syntax errors,
and offered that as the reason two arms of the walk recurse rather than being folded into the
`Clause(_)` catch-all: "the type permits what the parser does not produce".

The parser does produce it. Both bodies take `block(clause)`, and `clause` is an alternation
of five arms of which three are not access clauses -- a nested `block_clause`, a `when_block`,
and `parameterized_rule_call_clause`. So a rule reference reaches both bodies today, in two
spellings, each measured rather than reasoned about:

    rule a(t) { Resources { a(%t) } }                    Rule a references itself
    rule a(t) { AWS::EC2::Volume { a(%t) } }             Rule a references itself
    rule a { Resources { when a { Type exists } } }      Rule a references itself
    rule a { AWS::EC2::Volume { when a { ... } } }       Rule a references itself

and `parse-tree` shows the shape rather than the consequence: a `BlockClause`'s
`block.conjunctions` holding a `ParameterizedNamedRule`, and holding a `WhenBlock` whose
conditions hold a `NamedRule`.

The two examples the comment quotes are the one spelling that genuinely is absent. `clause`
has no `GuardClause::NamedRule` arm, so a bare rule name in either body is a syntax error at
the `{` -- and that single absence is the whole of it. It is also the arm the comment claimed
the type permits and the parser never produces, which is exactly inverted.

The arms stay; what changes is the recorded reason. They are live code carrying references in
two spellings, not a tripwire kept against a hypothetical grammar change. Deleting the false
sentence and leaving the rest was the alternative, and it is worse: it loses the reason the
arms exist, which is the thing the next reader needs before touching them.

Nothing pinned those two positions, so acting on the comment and folding either arm into the
catch-all as dead code would have left every test passing while restoring an undetected
cycle. `a_cycle_through_a_block_clause_or_type_block_body_is_reported` covers the four cases
above by message rather than by `is_err()`, because a syntax error and a cycle are both errors
and which one it is is the entire point. It carries controls in both directions: the same two
shapes naming a different rule parse, and the two bare-name spellings are asserted not to be
cycles. Writing the first control as `rule a(t) { Resources { b } }` is how this is easy to
get wrong -- that bare `b` is a syntax error too -- so the control keeps the call spelling and
says why beside itself.

8b3ac32's commit message carries the same false claim, with a "measured rather than assumed"
badge on it that discourages re-checking. That cannot be corrected in place; this message is
where the correction lives.
The `(Char, String)` arms added to `compare_values` last round made `eq` relate
`Char('b')` to `String("b")` -- `PartialEq`'s fall-through delegates to
`compare_values` -- while `Hash` kept hashing a `Char` through `Hash for char`
and a `String` through `Hash for str`. For `'b'` the hasher was handed
`62 00 00 00` in one case and `62 ff` in the other, so two values `eq` calls
equal had different hashes and the type stopped satisfying the `Eq` it asserts.

This is the same contract the same round had just fixed for `Map` entry order,
three commits earlier. The `impl Eq` doc comment was edited in the commit that
added the arms and still claimed the only relation reached through
`compare_values` was numeric widening, "and `Hash` agrees with it" -- true of
the relation it named and false of the one being added beside it.

No live consumer is affected and the commit claims none. Exactly one hashed
collection in the crate keys on a `PathAwareValue`: `by_lhs_value` in
`report_at_least_one`, whose sole caller dispatches under
`MapKeyComparator::In | NotIn`, so its keys are map keys and always
`PathAwareValue::String`. Swept with `git grep` rather than `grep`, because
`parser_tests.rs` holds six NUL bytes and plain `grep` reports no matches in it
at exit 1. 144 collection declarations across `guard/src`, `guard-ffi` and
`guard-lambda`, none other keyed on the type. So this is a latent unsoundness,
not a wrong verdict.

Fixed by making `Hash` agree rather than by withdrawing the relation. The
alternative was dropping the two `compare_values` arms, and it costs the round-I
F3 fix: there is no `Char` literal in the rules language, so a `Char` would go
back to comparing with nothing a rules file can write, the documented example
for `parse_char` in `docs/FUNCTIONS.md` would fail again for every input, and
eight cells of the operator matrix would revert. Solving F3 another way means
either `parse_char` returning a one-character `String` -- which breaks
`%c in r[a,z]`, the other half of what the function is for, since a `String`
does not compare with a `RangeChar` -- or adding a `String`-against-`RangeChar`
arm, which is the wider "does `Char` belong in the value space" change the arms'
own comment flags as open. Withdrawing a shipped usability fix to close a hole
with no live consumer is the wrong trade.

`encode_utf8` into a stack buffer rather than `to_string()`: the bytes go
through the same `str` impl the equal `String` uses, and it does not allocate.

`equal_values_hash_equally` covered `Int`/`Float` and `Map` order, which is why
it stayed green over the new arms. It now covers `Char`/`String` including
multi-byte and four-byte characters, the pair nested in a list, and three
negative controls. Verified by reverting only the `Hash` arm: the test fails on
"a char against the string that spells it".

The `impl Eq` comment now names both widenings and states the standing
obligation -- an arm added to `compare_values` widens `eq` and has to be matched
in `Hash` in the same commit.
…e diff

`!=` between a left operand carrying the root path and a document query failed
for two values that are comparable and unequal. `parse_int("7") != 1` exited 19
on a compliant template, and the reason read that `property [[L:0,C:0]] was not
present in [...Value=1]` -- a "was not present in" message under a `!=` clause,
about a subject the reporter could not place.

The negation wrapper computed `reverse_diff(qin.diff, &qin.lhs)` on a stated
premise: a diff taken from the right operand means the left had no unmatched
values, so every left value survives the filter and `!=` correctly fails. That
premise stopped holding when `EqOperation` gained the `placeable` guard in the
same commit. `placeable` is `any(|v| !v.self_path().0.is_empty())`, so it is
false for a non-empty set of root-path values -- which is what every
`parse_int` / `parse_char` / `parse_string` result and every rule parameter is --
and the middle branch then takes the right-hand diff *with the left still
holding unmatched values*. `lhs \ rhs_unmatched` for two disjoint operand sets is
all of `lhs`, so the reversed diff was non-empty and the clause failed.

`QueryIn::from_rhs`'s own doc comment stated the invariant the caller was
breaking: "only when every left-hand value did find a match".

Fixed by carrying `lhs_unmatched` on `QueryIn` and negating against that. The two
questions the struct was answering with one field are different: `diff` is what
the report should name, `lhs_unmatched` is which left-hand values failed, and
they are the same set only when the diff came from the left. Where the old
premise did hold the answer is unchanged -- with every left value matched,
`lhs_unmatched` is empty and the filter keeps all of `lhs`, which is what
`lhs \ rhs_unmatched` also gave, because a right-hand value with no equal on the
left cannot be equal to a left-hand value either.

Rejected: gating `placeable` on `lhs_unmatched.is_empty()`, which also restores
the wrapper's premise but costs the `==` reporting the guard was added for.
Measured rather than assumed -- for `%expected == %query` with `expected` a rule
parameter, this branch reports `ComparedWith = [9]` against the document property,
where the pre-round binary said `One was not present in [1]`, naming the value it
had just printed. Keeping both is what the extra field buys.

Verified: `iso2.guard` restores all eight cells to the pre-round verdicts,
`param.guard` returns to PASS at exit 0, `radius.guard`'s `!=` is the only one of
eight operators that moves, and `dup.guard`'s `==` stays FAIL so the
duplicate-values fix from the same round is intact.

`not_eq_passes_for_an_unreportable_left_operand` pins it, with the polarity
control (`56 != 56` still fails) and the `==` control that asserts
`diff_from == Rhs` and a placeable diff. It builds the left operand as a
hand-made `QueryResult::Resolved` with `Path::root()`, because that is what a
converter produces: a bare `let x = 7` arrives as `QueryResult::Literal` and
short circuits through `is_literal` before any diff exists, which is why
`test_operator_not_eq` and the obvious fixture both pass either way. Reverting
only the one expression fails the test on the unequal case.
`==` between two queries that both fails on several values and hits a pairing it
cannot answer reported one value. `%a.Properties.V == %c.Properties.V` over
`[1, 5]` against `["p", "q]"` named `A1` / `Value = 1`; `A2` / `Value = 5`
appeared nowhere in the output, so a template with N offending properties took N
runs to fix. The `(false, Some(..))` arm returned `not_comparable_because` and
dropped `query_in`, and every other unmatched value went with it. The pre-round
code reported one entry per failing value -- it named the wrong side, which is
what F10 fixed, but it did not lose values.

Second defect in the same arm: `unanswerable` recorded the first error seen
anywhere in either sweep, including one hit on the way to a match found
afterwards. `each` can refuse against one element of `against` and then match a
later one; the `continue 'each` skips the remaining comparisons but not the error
already stored. So `[1]` against `["x", 1]` failed on the right-hand extra and
reported that `1` is not comparable with `"x"` -- a pairing that decided nothing,
since `1` matched. Now held per value and promoted only once that value turns out
to have no equal, so the pairing named is always one that failed. Same fixture
after: the refusal names `B1` / `"x"`, the value with no equal.

The refusal now replaces the diff only when the diff names exactly the one value
the refusal already names. That keeps the single-value case -- the common one --
byte-identical, and `test_operator_eq_vs_in_from_queries` passes unchanged, which
is evidence it was pinning that case rather than the defect. No existing test
needed changing.

Two results rather than one reason carried on the diff, and this is the one place
the narrower shape lost. `InComparisonCheck` has a `message` field, so attaching
the reason there gives one finding per value with the reason on the right one. It
was tried and measured: `common.rs:871` maps that field to `NameInfo.message`,
the author's custom-message slot, where the `Comparison` variant beside it maps to
`NameInfo.error`. The reason then disappears from the human output and from
`--output-format json --structured` alike -- 0 occurrences of "not comparable"
against 2 before -- which is `7df7617`'s reason reporting undone. Making it render
means changing that mapping, which also currently discards `custom_message` for
this variant; that is a separate defect and a wider change than this one.

The refusal stays a `NotComparable` and is pushed first, because the negation
wrapper passes that through untouched and inverts `Fail`. Verified rather than
assumed: `!=` over two wholly incomparable operand sets is FAIL on this branch and
on the reviewed tree, and PASS at exit 0 on the pre-round binary.

Verdict-neutral. All 1536 cells of the operator matrix are unchanged in both
verdict and reason text, because every cell there is a single-value clause.

`a_refused_pairing_does_not_swallow_the_rest_of_the_diff` pins both defects, the
single-value case, and the `!=` polarity. Each half was checked by restoring that
half alone: reporting only the refusal fails the "diff beside it" assertion, and
recording inside the inner loop fails the "value that found no equal" assertion.
… change that

The comment on the two `is_nan` guards asserted a live failure: "with a
`Float(NaN)` in the document, `A not in r[-1,1]` succeeded, so a denylist admitted
a value that is not a number." That was true when it was written and is not now.
`335cb34`, later in the same round, gave the serde loader a finiteness gate
(`values.rs:317`), so a non-finite float becomes the string `.nan` / `.inf` /
`-.inf` instead. A reader reproducing the scenario finds it does not reproduce and
concludes the comment is wrong about something more important than it is.

Enumerated rather than asserted. Every way a `Float` is built from input gates
finiteness: the serde loader above, the libyaml loader at
`libyaml/loader.rs:215`, the rules-file float literal at `parser.rs:427` which is
a parse error, and `parse_float` at `functions/converters.rs:40` which yields no
value. `TryFrom<&serde_json::Value>` at `values.rs:382` has no gate, and cannot
need one -- JSON has no NaN spelling and `serde_json::Number::from_f64` rejects
one. Confirmed at the CLI: `1e400` in a JSON data file loads as the string
`"1e400"`, and `not in r[-1.0,1.0]` against it FAILs rather than passing.

The arm stays, and stays a refusal rather than `unreachable!()`. What makes it
unreachable is a property of the four gates and not of this match, so it returns
the moment any of them goes or a fifth construction site appears -- the same
argument the `Err(_) => false` arm in `PartialEq` already makes for itself, and
`unreachable!()` would turn each of those changes into a panic inside a
comparison. The comment now states which gates it depends on, so the next person
to touch one can see what they are turning back on.

Comment only; no non-comment line changed.
…t-wise

`compare_eq_symmetric`'s comment bounds the change carefully -- "Exactly the five
pairings the table has, and no others" -- and reasons about which callers the swap
reaches. It does not mention that one of those callers distributes over a list.
`EqOperation`'s literal-on-the-left branch unwraps a right-hand `List` and compares
the left operand against each element, so with a range on the left the swap turned
`%range == <query yielding a list>` from a refusal at exit 19 into element-wise
membership at exit 0. A comment that claims to have enumerated a change's
consequences and misses the widest one is worse than no comment.

No behaviour change, and the reasoning for that is now in the comment rather than
only in a review. Both alternatives are worse. Refusing on this side restores the
asymmetry the swap exists to remove: the mirror direction,
`<query yielding a list> == %range`, has distributed element-wise since before the
round through the same unwrapping in the opposite branch, and it passes on the
pre-round binary too. Making the literal spelling decide instead would make two
more comparisons start deciding and widen element-wise `==` further.

Probed rather than assumed, because "a comparison started deciding" is the risk
that dominates this file. The new answer discriminates: `r[0,10]` against a query
yielding `[1, 99]` fails, and against `[1, "x"]` fails in both polarities, so it is
not a rubber stamp. A one-element list answers the same way a bare scalar does,
and the bare-scalar cell is the F2 fix nobody disputes.

The open question is recorded where the next person will find it: the literal
spelling `%range == [1, 2]` refuses in both operand orders on both sides of the
round, so the language answers this comparison for a query and refuses it for a
literal. That axis pre-dates the swap, which widened it by one cell. Settling it
means deciding whether `list == range` ever meant membership.

Comment only; no non-comment line changed.
The merge test ran on the resolved key string, and by that point `"<<"` and
`<<` are both `String("<<")` -- `handle_scalar_event` puts a non-plain scalar
into `MarkedValue::String` and nothing carried the style forward. So a
document writing `"<<": plain string value`, an ordinary pair, was refused at
exit 5 for not giving the merge key a mapping.

YAML resolves the merge type from the plain scalar only: the merge key is
`tag:yaml.org,2002:merge`, an implicit resolution, and implicit resolution
applies to the plain style. Quoting is the documented way to write a key
literally named `<<`, so treating the quoted form as a merge both misread a
valid document and removed the one escape hatch for the name. PyYAML on the
same bytes keeps `{"<<": "plain string value"}` and merges the plain
spelling; cfn-guard answered both the same way.

The style is only known in `handle_scalar_event`, so the decision is recorded
there: `merge_key_index` holds the stack index of each plain untagged `<<`,
and `handle_mapping_end` tests position instead of name. The indices are
dropped when a container closes, because a `<<` written as a sequence item
rather than a key would otherwise be read as a merge key of whichever mapping
next occupied that index.

Measured, `merge-nonmap-quoted.yaml` against `merge-nonmap-plain.yaml`: the
quoted arm goes from exit 5 to loading with the key intact, the plain arm
still refuses at exit 5 with the same message and position, and every other
merge fixture -- explicit-wins, a sequence, nesting, and each unmergeable
value -- is unchanged.
… resolves

`apply_merges` claimed each name as it inserted it, so within one merge source
the *first* of two same-named entries won and the second was dropped. A plain
mapping does the opposite: the loader keys its map on `(name, location)`, so
both entries survive it and `path_value::try_from_marked` collapses them
last-wins and warns. Dropping the loser here also silenced that warning,
because `try_from_marked` is the only place a duplicate is visible.

So `<<: { Type: FIRST, Type: SECOND }` gave `FIRST` and said nothing while
`Type: FIRST` / `Type: SECOND` gave `SECOND` and warned -- one malformed shape
with two answers, and the quieter one on the spelling that is harder to read.
PyYAML gives `SECOND` for both. Claiming names once per source rather than per
entry lets the duplicate through to `try_from_marked`, which restores the
convention and the warning; skipping names already claimed by an *earlier*
source still gives the spec's earlier-entry-wins for a sequence of merges.

Two `<<` keys in one mapping changes with it, from earlier-wins to
later-wins. That is a duplicate key, which is what the previous reasoning
missed by analogising to the sequence rule instead, and the last value is what
cfn-guard does with a duplicated key everywhere else. PyYAML agrees. The other
loader in this product does not settle it either way: measured,
`serde_yaml::from_str` refuses a duplicate key outright -- `duplicate entry
with key "<<"` -- so it never reaches its merge handling at all, and this is a
divergence between the two loaders on duplicate keys generally rather than on
merges. The rstest case asserting earlier-wins encoded the old choice and now
asserts the new one.

Not restored: the "duplicate key `<<`" warning the pre-merge-fix loader
emitted for two `<<` keys, because both are removed from the mapping before
`try_from_marked` sees it and the loader has no warning channel of its own --
threading one changes `read_from`'s signature and every caller. The surviving
value now matches PyYAML, and the document is malformed either way.

Measured on the five merge fixtures: `merge-dupkey-inside` goes FIRST -> SECOND
and regains the warning, `merge-two-keys` goes FIRST -> SECOND, and
`merge-dupkey-plain`, `merge-both`, `merge-several` and `merge-nested` are
unchanged.
libyaml emits a whole document -- start, empty scalar, end -- for a `---` with
nothing after it, so the "any second `DocumentStart`" test made a file of one
template plus a trailing separator a two-document stream. The refusal was
wrong twice over: it said "holds 2" for a file holding one document, and the
one instruction it gave -- split them into separate files -- cannot be carried
out, because there is nothing at the `---` to put in the second file. A
trailing separator is what a generator or a concatenation leaves behind.

`count_remaining_documents` now counts the documents that hold something and
reports where the second such document starts, returning `Separators` when
every document after the first holds nothing. `load` then answers with the
document it has. It drains the stream before deciding, so returning there
cannot report compliance for bytes libyaml never read, which is the defect
the refusal exists for; the "at least n" lower bound for a stream that stops
parsing is kept for the same reason.

"Holds nothing" is the empty node specifically -- no tag, plain style, no
characters -- and not everything the loader resolves to `Null`. `~` and `null`
are written, so a document holding one still counts, which is asserted.

Measured against `9fee1a2` on rj-loader's fourteen document fixtures: the two
trailing-separator files go from exit 5 back to exit 0, `---`+`---` goes from
5 back to 19, and the real streams of 2, 3 and 4, the `...`-then-`---` file,
the malformed second document, the leading `---`, the trailing `...`, the
comment-then-`---` file and the comments-only file are all unchanged. Two new
edges measured: a separator *between* two real documents said "holds 3 -- the
second starts at" the separator and now says "holds 2" at the second real
document, and a trailing document holding a written `~` is still refused.
…ring

Narrowing `parse_bool` to the YAML 1.2 core set made the `!!bool` arm's
fallback reachable, and the fallback returned a string. So `!!bool yes` became
the string "yes": the one answer that neither reads the node as the boolean
the author explicitly asked for nor says it could not be read as one. Two arms
below, `!!int abc` is a `BadValue` reported at exit 5, so an out-of-set payload
under an explicit type tag was a hard error for `int` and a silent type change
for `bool`.

`serde_yaml` decides this the same way, measured payload by payload through
`guard test`: it accepts exactly the six spellings `parse_bool` accepts under
`!!bool` and refuses every other payload outright -- `y`, `on`, `off`, `no`,
`0`, `1` and `tRuE` included. So the set was already right and only the
fallback was wrong, and refusing here closes a divergence between the two
loaders rather than opening one.

PyYAML also errors on an out-of-set payload, but takes the YAML 1.1 words, so
`!!bool yes` is true there. Following that was the alternative and it is
rejected: PyYAML is a 1.1 implementation, and accepting 1.1 words under a tag
while refusing them plain would put a document's boolean vocabulary under the
control of whether a tag was written -- the same defect as the one the tagged
path had when it was stricter than the plain one, mirrored.

The comment on the arm claimed "a bare `yes` was already a boolean", which the
same round had already made false. It now says what the code does and what was
measured.

`!!bool` appears in no data file in either corpus, so the blast radius is the
`!!bool "false"` in `yaml_loader4`, whose payload is in the set and is
unchanged.
The round that rewrote the libyaml loader added a test to hold the two loaders
to the same answer, and left three of its own value-model fixes on one side of
the boundary. Two of the three are landed here.

`values::read_from` is reached only by `validate`. Everything else goes through
`TryFrom<&serde_yaml::Value> for Value`: `commands::helper::validate_and_return_json`,
which is re-exported as the public `run_checks` and is what `guard-ffi` and
`guard-lambda` call, and the `test` command's spec `input:` blocks. So until
now:

  - a template whose `Type` arrives through `<<` was invisible to
    `Resources[ Type == ... ]` through `run_checks` -- the silent wrong SKIP
    the libyaml fix existed for, still live through the FFI and the Lambda.
  - a `Mappings` block with an unquoted account id, file mode or bitmask as a
    key loaded under `validate` and was refused outright under `guard test`,
    at exit 255.

`merge_into` and `scalar_key_name` mirror `apply_merges` and
`stringify_scalar_key`, precedence and rendering both, and `MERGE_KEY` is now
shared rather than spelled twice. `serde_yaml::Value::apply_merge` was the
alternative and is not used: it would have to be called at every site that
reaches this conversion, so a new site would silently miss it, and it merges a
quoted `"<<"` for the same reason this conversion cannot avoid doing so.

The third fix, a float literal that underflows to zero, cannot land here.
`serde_yaml::Number` retains no source text, so `1e-400` arrives as
`Float(0.0)` and is indistinguishable from a literal `0`; refusing every
`Float(0.0)` would refuse a legitimate one, which is worse. It is not alone:
`serde_yaml::Value` is a resolved value model that has already discarded scalar
style and anchors, so a quoted `"<<"`, a YAML alias and a duplicate key are all
divergences this boundary cannot close either. The pin's doc comment now lists
all of them, says which are choices and which are limits, and names the only
cure -- one loader rather than two -- with its price: moving
`validate_and_return_json` onto `read_from`, the way `rulegen` moved in
fffbacf, costs the aliases `run_checks` accepts today, and `guard test` needs
more than that because a spec's `input:` is a `serde_yaml::Value` by the time
the command has it. Not done here.

The pin's document grows a `Mappings` block of eight non-string keys and two
merged mappings, one overriding an explicit key and one a sequence. Measured:
that document fails the assertion against `values.rs` as it stood, and passes
now.

`guard-ffi`'s `a_non_string_key_reaches_the_entry_point_as_a_parse_error`
asserted that all four key shapes come back as a parse error, which encoded
the refusal. Two of them -- a number and a boolean -- are now read, matching
`validate`; a null and a sequence key stay refused. The test is renamed for
what it still holds, that nothing panics, and carries the split.
The comment on this arm argues the change on the `k:` versus `k: ""` axis, which
is the axis it was decided on, and says nothing about the other consequence:
`empty` is refused on every non-container scalar, `Null` included, so moving the
empty node here moved the most common spelling of a null out of the one scalar
shape `empty` could answer.

`empty` and `!empty` over a `k:` are now both FAIL. An explicit `null` already
behaved that way, so the change widened an inconsistency rather than creating
one, and the refusal itself is deliberate -- the three states have three
predicates, and making `empty` hold for a null would make null the one scalar it
answers for. That decision is not reopened here.

What is new is a verdict flip with no edit to the template or the rule: a
`!empty` inside a filter used to select nothing and SKIP at exit 0, and now
bails and FAILs at exit 19, because the refusal is an error rather than a
verdict. That is what turns a CI gate red, so the comment now carries it,
together with both sides of the denominator, measured: 79 empty-valued keys in
13 of 246 registry data files and 5 in 4 of 115 here, against `empty` at bracket
depth in a `.guard` source 8 times in 7 of 210 registry files and once in 1 of
108 here.

Documentation only. No behaviour changes and no test changes.
`describe_key` and `stringify_scalar_key` sit twenty lines apart and took
opposite positions on whether a non-string key can be accepted at all.
`describe_key`'s was the stale half -- it still said rendering a resolved value
would "invent a name the document does not contain" and that accepting these
keys needs the source text carried through the value model, both of which the
function below it had already contradicted -- and it is the one the compiler
sends a reader chasing the refusal message to. It now says what it is for and
points at the decision rather than restating a reversed version of it.

`stringify_scalar_key`'s own justification was too strong in the other
direction. It claimed the resolved value "is exactly the key CloudFormation
sees" because CloudFormation resolves by "the same YAML 1.2 core schema this
loader implements", and this loader deliberately does not implement 1.2 core for
one spelling: `0755` is decimal 755 under 1.2 core and stays the literal here,
because 1.1 reads it as octal 493 and resolving either way invents a number.

Reading `0755 -> "0755"`, `0x1F -> "31"` and `1.0 -> "1.0"` as three conventions
is the trap that premise sets, and it is one rule: the key is the text of the
value the scalar resolved to. A `String` key never reaches this function --
`handle_mapping_end` has its own arm -- so `0755` is "0755" because the value
*is* the string "0755". The key inherits `resolve_int`'s trade rather than
contradicting it, which is the property a rule author needs: `0755` compares as
"0755" and looks up as "0755".

The float rendering is recorded against the renderer that disagrees with it,
which is not this one. `{f:.1}` matches `PathAwareValue`'s `serde_json`
conversion, so `-o json` prints the value `1.0` beside the key `"1.0"`;
`display::ValueOnlyDisplay` formats a float with Rust's `Display`, so the human
text report prints `1`. Left alone on measurement: whole-float values number 6
across both corpora and non-string keys 0, so changing the report's float
rendering would alter human output in the reporters' code to fix a collision no
file in either corpus can reach.

Documentation only. No behaviour changes and no test changes.
The loader work was amended after it was cherry-picked here, so this file
carried the pre-amend version while every other file in that branch carried the
final one. The commit subjects were unchanged by the amend, which is why the
integration check that compared subjects did not notice.

The difference is not cosmetic. The superseded version tested `num.is_i64()` and
then called `num.as_i64().unwrap_or_default()`, so an arm whose guard held but
whose accessor returned `None` would render the key as "0". A map key silently
rendered "0" is a lookup that misses, which is the class of defect this function
exists to close. The amended version binds through `if let Some(int) =
num.as_i64()` and falls back to `serde_yaml::Number`'s own `Display`, so no arm
can produce a default.

Also takes the amended doc comment on the two-loader divergence, which states
what each loader did with the same bytes rather than only that they differed.
…nnot pin

Three things, all in eval_tests.rs, none of them behavioral.

1. THE RECORDED REASON WAS INVERTED. `the_whole_value_pairing_stops_where_the_operator_
stops` said `PartialEq` has "no `(Int, Float)` arm", so `1` against `1.0` reached
`compare_eq` from both cells. Backwards: `PartialEq`'s catch-all delegates to
`compare_values`, which carries explicit `(Int, Float)` and `(Float, Int)` arms, so
`Int(7) == Float(7.0)` is TRUE through `PartialEq` -- measured directly. The `Float` cell
stopped at the FIRST early return, not the second.

The rule to pick a discriminator by is correspondingly opposite, and that is why this is
worth correcting in the tree rather than leaving in a pushed message. "Choose a type
`PartialEq` lacks an explicit arm for" selects nearly every scalar pair and leads a reader
straight back to `Float`. The rule is: choose a pair `compare_values` REFUSES that
`compare_eq` relates by some other route. A single-point `RangeInt` against an `Int`
qualifies -- `compare_values` will not order a range against a scalar, `compare_eq`
answers range membership.

2. THE SECOND INSTANCE OF THAT DEFECT. `a_short_circuited_scalar_pairing_earns_no_refusal`
still carried the `Float`/`Int` pair for the same purpose. Measured on it as it stood:
deleting either early return left it GREEN, while the positive control -- making the `Err`
arm record no refusal -- reddened cell 3. Its cells 1 and 2 bound NEITHER return. Cell 2
now uses a single-point `RangeInt`, and deleting `Ok(true) => return Matched` reddens it at
cell 2, as it already did for the sibling.

3. WHAT THE CELLS CANNOT PIN, WHICH IS A PROPERTY OF THE OPERATOR. Measured over every
explicit arm of `PartialEq` plus its fall-through -- `(Map, Map)`, `(List, List)`,
`(Bool, Bool)`, `(String, Regex)`, `(Regex, Regex)`, the three range arms, `(Null, Null)`,
`(Char, Char)`, `(Int, Int)`, `(Int, Float)` -- wherever `PartialEq` answers true,
`compare_eq` answers `Ok(true)` too. No pair the first early return catches escapes the
second, so deleting `if elem == each { return Matched }` changes no verdict and reddens
nothing, in either table. `stops_on_partialeq` is renamed `stops_on_a_matched_entry` in
both tests, because it never showed which check matched, and both comments now say so.
`Bool` was the obvious candidate for the missing asymmetry and does not work either;
`compare_eq(Bool, Bool)` is `Ok(true)`.

Also carries the mutation-matrix caveat that the cherry-pick of the element-loop fix
predated: the `(None, None)` arm's non-separability at the predicate is now measured rather
than argued, and cell 4's real dependency on `membership_pairing_refused` is disclosed --
it isolates the element loop GIVEN that the subset matches, not unconditionally.

Verification: the three mutations above run against both short-circuit tests, each test
invoked alone so its diff block is unambiguous, with --test-threads=1 throughout. Parallel
output splices `test ... FAILED` lines, which under-counts failures and reads as green --
that is how an earlier matrix run reported a red cell as passing.
`element_pairings_built` answers per ARM of `InOperation::compare`, and for a LIST
`rhs_value` the arm stops deciding: `contained_in`'s `(List, List)` arm reaches
`elements_not_matched`, which asks `is_one_of` per left-hand element, and every arm can
land there. So for a list right-hand value the honest answer is "always yes" and three of
the four rows the doc states are wrong. The contract was written unconditionally and is
false there.

MEASURED, not reasoned, with counters at the only two sites that pair a list's elements
against a right-hand value and ONE right-hand value per case so the count is attributable.
An earlier run used two values and could not tell which one built the pairings; its
`(None, None)` row read as agreeing for that reason alone. For a list `rhs_value`:

  (Some, Some)  predicted false, observed 2 pairings via elements_not_matched  DISAGREE
  (None, Some)  predicted false, observed 2                                     DISAGREE
  (Some, None)  predicted false, observed 2                                     DISAGREE
  (None, None)  predicted true,  observed 2                                     agree

THREE disagreements rather than one, and `(None, None)` is not among them. It agrees, but
not for the reason its row gives: its own element loop IS skipped by the
`if !eachr.is_list()` guard, and `contained_in` builds the pairings instead. A reader who
credits that row's own loop is wrong even where the answer is right, so the row now says
which half is load-bearing.

Latent, because the sole caller is `incomparable_membership`'s `(List, right)` arm and a
`(List, List)` pair is consumed by the arm above it, so `right` is never a list there. That
guarantee is real and invisible -- enforced by match-arm ordering and written down nowhere,
in a `pub(super)` helper whose entire reason for existing is that a condition restated away
from the dispatch can drift from it. One reordering turns three wrong answers live.

So the precondition is stated in the doc AND asserted with `debug_assert!`, which is proven
live rather than decorative: passing a list `rhs_value` panics under the debug profile and
returns normally under release, both measured. No existing caller trips it -- both tables
pass in debug.

Also corrects the `(None, Some)` row's justification. It said `string_in`'s refusal is "the
same partition" `pair_refused` makes. False: `string_in` answers `not_comparable` for
`(Regex, String)`, `(String, Regex)` and `(Regex, Regex)` while `compare_eq` answers
`Ok(true)` for all three, so `pair_refused` refuses none of them -- three disagreements out
of four probed pairs, only `(Int, String)` agreeing. The claim is also unnecessary: the gate
needs element pairings to EXIST there, not the two functions to classify them alike. It is
unreachable, a `PathAwareValue::Regex` being built only by the rules parser and a rule
literal on the left selecting a different arm, but an unreachable justification is still the
wrong one to leave standing.

Doc plus one `debug_assert!`; no behavioral change in release and no test added.
`an_element_pairing_the_arm_does_not_build_earns_no_refusal`'s "What these cells do
NOT pin" section said "Flipping each of the four arms in turn moves exactly one cell
here and one in the helper's table". That contradicted its own neighbour six lines
up, which records that forcing `(None, None)` to `false` reddens the helper's cell 1
and moves nothing in this table.

The tree held the correct fact, the reason for it, and a summary that generalized over
a set the reason had just excluded. Structural, not a measurement slip: every cell
carries a `Literal` on one side, so cells 1-2 reach `(None, Some)`, cells 3-4
`(Some, None)` and cell 5 `(Some, Some)`. `(None, None)` needs both operands queried
and no cell here is one. Verified by reading the five constructions against
`is_literal`'s `len() == 1 && Literal` rule, and independently by measurement --
`(None, None) => false` leaves this table all green, 0 of 5 cells, while the helper's
cell 1 moves `true -> false`. The reading it invited, that this table gives
`(None, None)` cell-level coverage, is exactly what the paragraph above exists to
prevent.

Also names the mutation, because "exactly one cell" is ambiguous for the two arms
carrying sub-conditions. `(None, Some)` branches on whether the right operand is a
`String` and `(Some, None)` on `any(is_list)`, so negating either flips both
sub-branches and moves two cells, while substituting `true` and then `false` moves one
each. Six substitutions across the four arms -- one apiece for the two arms already
constant, two apiece for the others -- is what was measured.

And discharges the inert-vs-untested ambiguity in the redundancy claim next door with
numbers rather than a green result: deleting `if elem == each { return Matched }`
leaves the lib target at 1430 passed / 0 failed, while deleting
`Ok(true) => return Matched` from the same loop produces 20 failures. The site is
reached; the first branch changes nothing when gone.

Records one limitation of that redundancy measurement. The walk asks `elem == each`
then `compare_eq(each, elem)`, operands reversed between the two, and the asymmetry
cell 2 relies on is one-directional. Both directions were checked for the two pairs
the cells turn on and one direction only for the remaining arms; a later 2116-ordered-
pair probe in the loop's own order found no violation and 15 pairs where `compare_eq`
matches while `PartialEq` does not. The conclusion holds, and the narrower basis is
written down because "every arm" invites a reader to assume both orders were walked.

Comment-only: every changed line is a doc comment, no test added or removed.
`two_rules_files_sharing_a_basename_get_distinct_locators` checks that
each notice carries its own parent directory, and spelled the fragment
with a forward slash. The locator renders the path with the platform's
separator, so on Windows the notices read
`...\first\incomparable_membership.guard`, neither fragment matched, and
the windows-latest job failed while Linux and macOS passed.

Match against a copy with the separators normalized. Each parent still
matches exactly one notice, so the assertion keeps the discriminating
power it was written for.
@awsmadi

awsmadi commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

@Zee2413 @satyakigh — worth a look ahead of the other open PRs, for two reasons.

CI is fully green at 05edeef (run 34234046182): all 16 jobs, including the three aws-guard-rules-registry integration jobs and Testing Windows Install Script (install-guard.ps1). That Windows job is currently the only failing job on main (run 34151842199 at 814bd00a).

This PR also adds .github/scripts/check-registry-corpus.sh, which asserts the pinned registry corpus is in its known state instead of demanding an exit code the corpus cannot produce. That is what clears #720. #720's integration jobs fail for a reason that is not a defect in it: it escalates unchecked test expectations from a stderr note to a hard error, and the pinned corpus carries 30 of them in rules/aws/aws_cloudformation/tests/cfn_no_explicit_resource_names_tests.yml. That suite asserts 11 rule names (static_name, static_rolename, no_static_names, and eight others) that its .guard never declares; the file declares only CFN_NO_EXPLICIT_RESOURCE_NAMES. Verified by building #720's binary, running it against the pinned corpus (exit 1), removing exactly those 30 expectation lines, and re-running (exit 0). So #720 needs no code change, only this harness.

One other thing only a maintainer can unblock: the registry-side fix for those stale expectations is aws-cloudformation/aws-guard-rules-registry#288, whose CI has never run because it is sitting in action_required awaiting workflow approval.

awsmadi added a commit to awsmadi/cloudformation-guard that referenced this pull request Sep 8, 2026
…erence-to-inapplicable question toward aws-cloudformation#727

Two conflicted paths, and eval.rs was not a textual conflict: the two branches
held opposite, separately-argued positions on what a reference to a rule that
did not apply means.

This branch mapped it onto a violation for assertions --
`(Outcome::NotApplicable, _) if role.is_strict() => Outcome::Violated` -- on the
ground that "a rule body asserting `r(...)` claims that `r` holds, and a rule
that never ran is not evidence that it does", and flagged the resulting change
from main as deliberate. That reasoning is true and the conclusion drawn from it
is not: the answer to "no evidence" is no verdict, not a violation.

Resolved toward aws-cloudformation#727 for two reasons.

It produced a false positive. With two helper rules each gated on their own
resource type, a template carrying one clean instance of the first type and
none of the second reported FAIL at exit 19, and so did a template carrying
neither type -- a violation asserted about a resource type the template does
not contain. The row that matters for correctness, a violating instance beside a
clean sibling, still FAILs, so this is not a slide into disjunction.

It contradicted this PR's own algebra. `Outcome::and` has identity
`NotApplicable` and absorbing `Violated`, so one level down an inapplicable
clause conjoined with a satisfied one yields satisfied. Mapping that same
`NotApplicable` onto the absorbing element at the reference site left the type
saying "not applicable is neutral" while this arm said "not applicable is
fatal", for one input. Whichever way the question is answered, the reference
site and `Outcome::and` have to answer it the same way, and only one answer is
expressible without carving an exception out of the type.

Both sites are resolved, `eval_guard_named_clause` and
`eval_parameterized_rule_call`, and both matches stay exhaustive over the enum
rather than ending in `_`: that catch-all had been silently covering both a
failing dependent and a negated gate on an inapplicable one. The negated `when`
gate is unchanged -- a gate that closes silently disables the rule it guards,
and the AWS rule registry's one negated reference is a gate. The comments
arguing for the old behaviour are removed rather than left beside code that now
does the opposite.

eval_tests.rs was additive on both sides; all twelve test functions are kept.
awsmadi added a commit to awsmadi/cloudformation-guard that referenced this pull request Sep 8, 2026
aws-cloudformation#727 gained one commit: an empty expansion yields an empty selection only when a
filter is still pending at or after the point the collection came up empty, so
the two flavours of empty selection stop disagreeing.

Merged clean textually. Verified behaviourally rather than on that basis, because
the previous merge from this branch was also textually clean and had aws-cloudformation#727's
Ok/Status::FAIL arms meeting this branch's Result/Outcome return type, which git
cannot see.
@awsmadi
awsmadi force-pushed the pr/filters-captures-and-reporter-silence branch from 05edeef to 9a4cc06 Compare September 8, 2026 16:03

@satyakigh satyakigh left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two actionable issues at current head 9a4cc06:

  • Blocking — valid JSON integers above i64::MAX still sign-flip on the public run_checks path. guard/src/rules/values.rs fixes the serde_yaml::Value arm by preserving the digits, but the serde_json::Value arm still does num.as_u64().unwrap() as i64. run_checks parses valid JSON through that arm before its YAML fallback. I reproduced 18446744073709551615 with a rule Size < 0: the rule reports PASS; changing this arm to preserve the decimal string makes it FAIL. Please apply the same fix to the JSON arm and add a regression through run_checks (which also covers Lambda/FFI callers).
  • FFI error-code contract is incomplete. get_code now returns 25 for UnsupportedDocument and 26 for UndecidableComparison, while guard-ffi/src/lib.rs still documents 1..=24, and every_error_code_is_distinct_and_usable omits both constructible variants. Please update the documented range and include 25/26 in the distinctness test.

@awsmadi

awsmadi commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up, since the branch and its CI both moved after my comment above.

The branch was force-pushed to rewrite one commit message. The rewrite was message-only: trees are byte-identical between the old and new tips, and git diff between them is empty. So no check outcome can differ because of it. Current head is 9a4cc06d.

Current CI, run 34248709375: 17 pass, 1 fail. The single failure is Testing Windows Install Script (install-guard.ps1), and it is not caused by anything in this PR:

  • The same job fails on main — run 34151842199 at 814bd00a — with the same error.
  • install-guard.ps1 queries api.github.com/repos/aws-cloudformation/cloudformation-guard/releases/latest unauthenticated, so from a shared runner IP it hits the API rate limit. The job dies in about 20 seconds.
  • That script installs the last published release. It never reads this PR's source and never compiles the crate.

Everything else is green: all three aws-guard-rules-registry integration jobs, all three unit-test platforms, both fuzz targets, clippy, fmt, shellcheck, typos, and both install-guard.sh jobs.

My earlier 16/16 figure was measured on run 34234046182 at 05edeef8, which still reports success — that commit is simply no longer at the branch head.

Neither I nor the author can re-run the failing job; we do not have admin on this repository. So it stays red until someone who does re-runs it, or a later run gets past the rate limit.

An integer above `i64::MAX` sign-flipped through `TryFrom<&serde_json::Value>`,
which is a separate arm from the `serde_yaml` one that was already fixed. The
public `run_checks` tries `serde_json::from_str` first and only falls back to
`serde_yaml` when that fails, and `18446744073709551615` is valid JSON -- so the
entry point `guard-ffi` and `guard-lambda` both call took the arm that still read
`u64::MAX` as -1, and `Size < 0` reported PASS at exit 0 with nothing on either
channel.

The digits are kept, matching `convert_yaml` so the two arms agree on the same
input. The regression goes through `run_checks` rather than the conversion, since
that is the surface the sign flip was reachable from and the one the FFI and
Lambda callers share; it reports PASS before this change and FAIL after.

No finiteness gate on the float arm beside it, unlike the YAML one: `serde_json`
refuses a non-finite float while it is still text -- `1e400` is `number out of
range` at parse time and JSON has no `.inf` spelling -- so the gate would be
dead. The comment on `try_from_json` loses the sign-flip clause it used to give
as a reason not to adopt `serde_json`, which no longer holds.
`get_code` gained `UnsupportedDocument` (25) and `UndecidableComparison` (26),
but the header comment in `lib.rs` still told C callers every non-zero code is in
`1..=24`, so a caller switching on the code had two documented-as-impossible
values to meet.

`every_error_code_is_distinct_and_usable` did not reach either one. Both carry a
`String`, so neither had the unconstructible-payload reason the five absent
variants have -- they were simply added after the test was written. They are also
the two nearest a collision, being the highest codes and sitting on the far side
of the 23/24 input codes, so an arm added later that reuses one is exactly what
this assertion is for.
@awsmadi

awsmadi commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Both addressed at 8447892. Thanks for reproducing the integer case — that made it quick to confirm.

The JSON arm. Reproduced your case before changing anything: { "Size": 18446744073709551615 } against rule r { Size < 0 } through run_checks returned "status": "PASS" with r listed as compliant. You were right about the path, and about why the earlier fix missed it — run_checks tries serde_json::from_str first and only falls back to serde_yaml when that fails (commands/helper.rs:51), and that integer is valid JSON, so it never reaches the arm that was fixed.

The JSON arm now keeps the decimal string, matching convert_yaml so the two agree on the same input. The regression goes through run_checks as you asked, in guard/tests/functional.rs, which is the surface cfn_guard_run_checks and the Lambda handler share. It fails with PASS before the change and passes after.

One thing I checked while there and deliberately did not change: the float arm beside it has no finiteness gate, unlike the YAML one. serde_json refuses a non-finite float while it is still text — 1e400 is number out of range at parse time, measured — and JSON has no .inf or .nan spelling, so a serde_json::Number reaching that arm is always finite and the gate would be dead code. The YAML arm needs one because the libyaml loader does accept those spellings. There is a comment recording that so the asymmetry does not read as an oversight.

The doc comment on try_from_json used to cite this sign flip as one of three reasons not to adopt serde_json. That reason no longer holds, so it is gone; the other two stand.

The FFI contract. Also correct, and the documented range was worse than stale — it told C callers that 1..=24 was exhaustive while get_code could return two values outside it. The header now says 1..=26 and names both. every_error_code_is_distinct_and_usable covers them: both carry a String, so neither had the unconstructible-payload reason the five absent variants have, and the docstring now says so rather than leaving the omission unexplained. They are also the two closest to a collision, being the highest codes and sitting past the 23/24 input codes.

Measured on 8447892: cargo test --workspace is 3196 passed / 0 failed locally, and both CI workflows are green — all 16 jobs on the Pull Request workflow, including the Windows install-script job that had been failing.

…er still does not

`0755` became the literal text, on the reading that YAML 1.1 makes those characters octal 493 while
1.2 core makes them decimal 755, so resolving to either quietly picks a schema. That reading is
defensible about the ambiguity and wrong about the consequence.

An unresolved scalar becomes a `String`; `compare_values` has no arm for a string against an integer;
and in a `when` condition a clause with no answer means the condition does not hold. So
`when Mode < 1000 { ... }` over `Mode: 0755` stopped evaluating its body and exited 0, where the same
rule and document exit 19 without this. Measured against a build of the 3.2.1 tag, which resolves it
to 755 and reports the body's failure. Silently declining to check is worse than choosing the reading
this loader already had, and every leading-zero decimal fits `i64`, so the direction costs nothing.

The `0x`, `0o`, `0X` and `0O` resolution stays. It removes triggers rather than adding them.

An integer above `i64::MAX` still keeps its text, and that is now a stated choice between two measured
harms rather than a gap. Resolving it to a float answers ordering correctly and reports two distinct
integers as one: `9223372036854775809` and `9223372036854775810` both become `9.223372036854776e18`,
so a clause asserting they differ is reported non-compliant -- measured on builds of both 3.2.1 and
the default branch. Keeping the text gets identity right and leaves an ordering comparison with no
answer, which is the gate consequence above. Neither is correct; `MarkedValue::Int` is an `i64` and no
float holds `u64::MAX`, so the option that avoids both needs a value space that can hold the value.
Exact identity is preferred here because a reported violation that does not exist costs a reader more
than a rule that declines to answer, but that is a choice of harm and it belongs to whoever owns the
value space.

The registry corpus check exits 0 with this change. That number can fail: it exits 1 for a separate
experiment on the same branch that reclassified comparison refusals.
… drop two vacuous assertions

Three corrections, all from measuring claims this branch had asserted without checking.

The comment saying the two loaders agree on an integer above `i64::MAX` is true only for a plain
decimal. They diverge on every other spelling of the same value, because the serde arm writes what
`u64::to_string` gives while the libyaml loader keeps the source text: `0xFFFFFFFFFFFFFFFF` becomes
`18446744073709551615` on one side and stays hex on the other, and a leading `+` is dropped on one and
kept on the other. `a_spelling_the_two_loaders_read_differently` pins both readings of each, and of
`0755`, whose two readings this branch has just changed.

Those three spellings come out of the agreement document rather than staying in it, because they do not
agree and cannot be made to without provenance the value space does not carry. Moving them into a test
that pins both sides is the point: an agreement claim that quietly excluded them would be weaker than
one that names them, and a new divergence still fails the agreement test.

`Size < 0` through `run_checks` is replaced. It did detect the sign flip -- measured, the released
binary reports PASS for it and this branch reports FAIL -- but it cannot say which representation
replaced the negative number, since a float, a decimal string and a clamped integer all report FAIL.
`Size > 100` discriminates nothing at all: the flipped `-1` fails it and a refusal fails it, and both
binaries report FAIL. The identity comparison is what separates the representations, and it is
asserted instead: the released binary reports FAIL and this one PASS.

The ordering and gate consequences are asserted too, as a recorded cost rather than intended
behavior. `18446744073709551615 > 100` is true, so PASS is the right answer for the clause and running
the block is the right outcome for the gate; neither happens. The assertion says so, and says what the
alternative representation would trade for it.
`every_error_code_is_distinct_and_usable` reaches both variants and asserts each code is positive,
distinct and not 19. Those are properties, and properties survive an identity swap: exchanging the two
literals in `get_code` leaves that test green while the header in `lib.rs` says 25 is an unsupported
document when it has become an abandoned comparison, so a C caller switching on 26 gets the wrong
error class. Verified by making that swap -- the new test fails and the distinctness test does not.

22, 23 and 24 are already pinned exactly in this file. These two were the codes this change touched and
the only ones left unpinned. The documented maximum is asserted as well, since the header is a promise
to a C caller and nothing else checked it.
…uietly

The three spellings taken out of the agreement document are now a closed set. Without that, a fourth
divergence could be added to the pinning test and removed from the agreement document and both would
stay green -- the exclusion list absorbing a new problem, which is what makes an exclusion list worse
than none.

Also states why the exclusion is scoping rather than weakening, because the two look alike in a diff.
The agreement test still compares whole documents exactly, so a spelling that starts diverging fails
it; what changed is which spellings are in its input, and each of those is pinned on both sides
elsewhere. Weakening would have been relaxing the comparison itself, which would have left it unable
to detect any divergence at all.
@awsmadi

awsmadi commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Measurements on numeric scalar handling

What this is, since it is posted on a pull request and is mostly not about it. Parts 1, 2, 4 and 5
describe behavior present at the 3.2.1 tag and at main, independent of this branch. Part 3 reports
what a candidate fix for part 2 costs, measured, and the numbers argue against making it. Where this pull
request is relevant it is said in place: part 1 notes that this branch does not fix the result it
describes, and part 5 notes which of two representations the branch keeps and why.

Two questions are left open rather than answered — one at the end of part 5, one in its own section
before the reproduction steps. Both need knowledge of intent that is not derivable from the code, so
they are for the maintainers rather than for this review.

Posted here because the measurements came out of this branch and the reproduction steps are easiest to
follow beside it, not because any of it is a claim about the diff under review.

All figures below were produced by building two references from source and running the binaries:

  • the 3.2.1 tag, which is the current release
  • this repository's main at 814bd00a4e6d761e8b5c9f615dd510b1a2a7c374

Both are named exactly because a fork's main can sit behind this one, and an earlier draft of these
figures was taken against such a fork before the divergence was noticed. Every [upstream] marker
below means verified present at the 3.2.1 tag, at that main commit, or both. [unmerged] means the
code exists only in an open pull request and is named where that distinction changes the reading.

Every command was run twice: once with a value that must reach the comparison, as a control that the
rule and the assertion can produce a failure at all, and once with the value under test.


The three questions, up front

These are the parts nobody outside the project can answer. Each is discussed where it arises; they are
collected here so they are not buried.

  • Does suppression depend on an incompatible pair answering "no"? If it does, the 131 moved
    suppression expectations in part 3 are a correctness problem becoming visible rather than one being
    caused. If it does not, that change is wrong as written. This is the most useful thing in the report
    and the one part left open. Detail in The question this cannot answer, below.
  • Which of the two harms should the tool prefer, for an integer above i64::MAX: a comparison that
    refuses to answer, or one that answers from a value the document does not contain? Detail in part 5.
  • Is making the value space hold these integers exactly worth doing, or is the range far enough
    outside real templates that either reading is acceptable? Detail in part 5.

1. A JSON integer above i64::MAX reaches the comparison as a negative number

guard/src/rules/values.rs [upstream], in impl TryFrom<&serde_json::Value> for Value:

} else if num.is_u64() {
    //
    // Yes we are losing precision here. TODO fix this
    //
    Ok(Value::Int(num.as_u64().unwrap() as i64))

as i64 reinterprets the bit pattern rather than converting, so 18446744073709551615 arrives as
-1. The comment reads it as a precision loss; it is a sign change.

This arm is reached from validate_and_return_json, exported as cfn_guard::run_checks
(guard/src/lib.rs) [upstream], which tries serde_json::from_str first and falls back to
serde_yaml only when that fails (guard/src/commands/helper.rs) [upstream]. An integer above
i64::MAX is valid JSON, so it takes the JSON branch. cfn_guard_run_checks in guard-ffi and the
handler in guard-lambda both call that function. The cfn-guard CLI does not; it reads documents
through a different loader and is unaffected.

Reproduction

Rule:

rule r when Mode > 1000 {
    Public == false
}

Called through run_checks with verbose = false:

data 3.2.1 [upstream] main [upstream]
{ "Mode": 5000, "Public": true } — control "status": "FAIL" "status": "FAIL"
{ "Mode": 18446744073709551615, "Public": true } "status": "SKIP" "status": "SKIP"

The control confirms the rule and the assertion can report a failure.

18446744073709551615 > 1000 is true, so the condition holds, the block is entered and
Public == false is evaluated against true. That is the FAIL the control shows. With the value
under test the condition is evaluated as -1 > 1000, which is false, so the rule does not apply, the
block is not evaluated, and the returned status is SKIP.

The same rule read by the CLI reports FAIL on an equivalent YAML document, so the two entry points
disagree on the same rule and the same value.

Note on the open pull request

An open pull request changes this arm to keep the decimal digits instead of casting. That removes the
sign change, and for a bare assertion such as Size < 0 it changes a wrong PASS into a FAIL. It
does not change the result above: the value becomes a string, an ordering comparison between a
string and a number has no arm in compare_values, and the condition again does not hold, so the
status is still SKIP. Measured on that branch, the SKIP cell is unchanged. This report should not
be closed by that pull request merging.


2. KNOWN_ISSUES.md item 2 does not describe the behavior inside a when condition

docs/KNOWN_ISSUES.md item 2 [upstream] reads:

When performing != comparison, if the values are incompatible like comparing a string to int,
an error is thrown internally but currently suppressed and converted to false to satisfy the
requirements of Rust's PartialEq. We are tracking to release a fix for this issue soon.

That describes what the comparison returns. What it does not describe is that the consequence differs
by where the clause sits, and the two differ in whether the run says anything at all.

As a bare assertion, an incompatible pair fails the clause and the report names it, so an author sees
the mismatch and can correct the rule.

Inside a when condition, the same pair means the condition does not hold. eval_rule maps every
non-PASS condition to SKIP for the rule [upstream], so the rule does not apply, the guarded
block is not evaluated, and the run exits 0. Nothing is written to either output stream about the
block that did not run. An author reading item 2 would expect a visible failure and would get silence.

This is a documentation observation, not a second defect: the behavior follows from item 2 plus the
condition-to-SKIP mapping. It is raised because the combination is what a rule author meets, and
because item 2 is the page they would read first.


3. Measured cost of treating an incompatible pair as undecided (143 registry expectations move)

3. Measured cost of treating an incompatible pair as undecided

One candidate fix for the when-condition behavior in part 2 is to treat a refusal to compare two kinds
as an unevaluatable clause rather than as a decided "no", so a condition that cannot be answered fails
its rule instead of leaving it not applicable. In this repository's own terms that is adding
Error::NotComparable(_) to the match in is_unevaluatable (guard/src/rules/eval.rs) [upstream],
which today matches Error::IncompatibleError(_) alone; ClauseRole::is_strict beside it already
carries the FAIL-rather-than-SKIP decision.

That was implemented and measured. The numbers are given here because they argue against it, and
because they are reproducible with nothing but this repository and the registry.

Against the aws-guard-rules-registry corpus at commit
7f7340c26ae5d5e8874651dbffeb12e0e9f505b6, running cfn-guard test -d rules over the whole corpus:

  • 143 rule expectations move. 131 are Expected = SKIP, Evaluated = FAIL; 12 are
    Expected = PASS, Evaluated = FAIL.
  • The comparison that produced those figures was a diff of the corpus report between a binary built
    with the change and one built without it. Both runs exit 1 at this corpus commit for an unrelated
    reason — 30 expectations name a rule their own file does not define — so the exit code alone does not
    separate them and the report has to be diffed. A helper that does the diffing arrives with the
    pull request these figures come from; it is not in this repository yet, and nothing here depends on
    it. The 143 is reproducible with diff over two saved reports.
  • The corpus commit above is pinned in the pull request's workflow [unmerged], not in this
    repository's main [upstream], where the registry checkout tracks the default branch. Either way
    the corpus lives in a different repository and its expectations cannot be corrected from here.

The 12 PASS → FAIL cells are the ones worth naming, because a rule whose expectation is PASS
describes a template the registry asserts is compliant, and it is now reported as a violation. Named
cases include:

  • LAMBDA_NO_WILDCARD_PRINCIPALS, on the case named "Lambda permission policy limited to an account
    as the principal, PASS"
  • IAM_USER_LOGIN_PROFILE_PASSWORD_RESET_RULE, on "IAM User with Login profile and PasswordReset set
    to true"
  • SECURITY_GROUP_EGRESS_ALL_PROTOCOLS_RULE and SECURITY_GROUP_INGRESS_ALL_PROTOCOLS_RULE
  • EMR_SECURITY_CONFIG_ENABLED_AND_CONFIGURED_RULE

The 131 SKIP → FAIL cells are suppression cases, including several IAM_USER_LOGIN_PROFILE_* tests
named "Guard suppressed" and "CFN_NAG and Guard suppressed".

An independent check inside this repository agrees. A test that holds every operator and operand shape
against a stated oracle [unmerged] reports 24 cells moving onto the disagreeing-with-oracle list
and none coming off it
. Among them are == 50 against the string "50" and > 10 against the
string "50", in a condition role — a numeric-shaped string against a number, which is a shape rules
use. The oracle is this repository's own statement of the right answer, so those 24 are wrong by the
project's own definition rather than by an outside judgment.


4. A numeric-looking mapping key is addressed differently by the two loaders

4. A numeric-looking mapping key is addressed differently by the two loaders

Independent of everything above, and [upstream] at both references.

cfn-guard validate reads a document through the libyaml wrapper; cfn-guard test and run_checks
read one through serde_yaml. The two resolve a numeric-looking mapping key differently, and because a
resolved key is rendered back into the string a rule addresses, the property's name differs by entry
point.

Measured with 0755 as a key:

entry point key as addressed
cfn-guard validate 755
serde_yaml-backed readers 0755

So a rule referencing that property resolves under one command and finds nothing under the other.

Not every numeric-looking key does this. 0x1F is resolved to 31 by both, so it is addressed
identically either way; the divergence needs a spelling the two disagree about, and a leading-zero
decimal is the one measured here.

The trigger is a mapping key whose text the two loaders resolve differently, and the failure is a rule
that finds nothing rather than one that matches wrongly. Included because it has the same root cause as
the rest of this report — two loaders reading one document — and because it is not visible from either
loader on its own.


5. An integer above i64::MAX has two representations and both are wrong

5. An integer above i64::MAX has two representations and both are wrong

MarkedValue::Int is an i64 and no f64 holds u64::MAX, so a value in that range cannot be
carried exactly by anything the value space currently offers. Both available readings were built and
measured.

Resolved to a float, which is what 3.2.1 and main do [upstream]: ordering answers correctly,
and identity does not. 9223372036854775809 and 9223372036854775810 both become
9.223372036854776e18, so a rule asserting the two differ is reported as a violation. Measured on
builds of both, A != B over those two values exits 19, and the report reads
provided value [9.223372036854776e18] did match expected value [[9.223372036854776e18]].

Kept as the source text: identity is exact and the two values above compare as different, which is
correct. An ordering comparison against a number then has no arm in compare_values, so as a bare
assertion the clause fails closed and, as a when condition, the rule does not apply and the guarded
block is not evaluated — the behavior described in part 2.

Neither avoids both. The only reading that does is one the value space can hold exactly, which is a
change to PathAwareValue and everything downstream of it — comparison, the serializers and SARIF —
and therefore not something to decide from outside the project.

An open pull request currently keeps the text, on the reading that a reported violation which does not
exist costs a reader more than a rule that declines to answer. That is a choice between two harms made
by someone who does not own the value space, which is why it is raised here rather than settled there.
It also has a cost worth stating plainly against itself: resolving to a float instead would fix the
run_checks result in part 1, because a float compares against a number numerically. So the reading
that is wrong about identity is the one that answers part 1 correctly.

Two questions:

  • Which of the two harms should the tool prefer, if it has to choose?
  • Is making the value space hold these integers exactly something worth doing, or is the range far
    enough outside real templates that either reading is acceptable?

The question this cannot answer

The 131 suppression cells admit two readings, and they lead to different work:

  1. Suppression depends on an incompatible pair answering "no". If so, those 131 are a correctness
    problem becoming visible, and the classification change is surfacing it rather than causing it.
  2. Suppression is independent of that, and the change breaks it. If so, the classification change is
    wrong as written and the fix belongs elsewhere.

Distinguishing these needs knowledge of how the suppression mechanism is intended to interact with an
unanswerable clause, which is not derivable from the corpus or from this repository. It is the most
useful thing in this report and the one part left open.


Reproducing every measurement above

Reproducing

Every measurement in parts 1 through 5 is reproducible with nothing but this repository and the registry
repository. The one exception is the operator-matrix count inside part 3, which comes from a test that
exists only in the pull request; it is noted again at the end and is offered as corroboration rather
than as a step to follow.

Parts 1, 2, 4 and 5. Build the 3.2.1 tag. For part 1, call cfn_guard::run_checks with the rule
and the two JSON documents given there, once with 5000 as the control and once with the wide integer,
and compare the status field. For part 2, run the same rule shape through cfn-guard validate on an
equivalent YAML document and note that it reports FAIL where run_checks reports SKIP. For part 4,
put 0755 in as a mapping key and read the same property through validate and through test. For
part 5, put 9223372036854775809 and 9223372036854775810 in one document and assert A != B.

Part 3. Check out aws-guard-rules-registry at 7f7340c26ae5d5e8874651dbffeb12e0e9f505b6. Run
cfn-guard test -d rules from the registry root, saving the output, once with a binary built from
main and once with a binary carrying the change described in that part.

Stated in this repository's own names: is_unevaluatable in guard/src/rules/eval.rs [upstream]
currently matches Error::IncompatibleError(_) alone, and ClauseRole::is_strict beside it is what
decides that an unevaluatable clause fails rather than skips. Adding Error::NotComparable(_) to that
match is the change — a refusal to compare two kinds becomes an unevaluatable clause, so a condition
holding one fails its rule instead of leaving it not applicable.

Then diff the two saved reports. The 143 figure is the count of Expected = ..., Evaluated = ... lines
that appear in the second and not the first. Both runs exit 1 at this corpus commit for the unrelated
reason noted above, so the exit code is not the signal.

One precision about that figure: it was measured on the pull request's branch, where the same decision
is reached through an intermediate classification the branch adds, rather than by editing
is_unevaluatable on main directly. The two are the same behavioral change and the count is expected
to match, but only the first was run. If the number differs on main, the main number is the correct
one.

Part 3, the operator matrix. That result comes from a test that exists only in the pull request, so it is
offered as corroboration rather than as something to reproduce here.

The single-line #[case::wide_leading_plus(...)] runs to 102 columns, past
rustfmt's 100-column max_width, so the formatting check failed while every
other check on this branch passed.

This is rustfmt's own output applied verbatim. The three case arguments are
unchanged, so the test still runs the same spelling and expects the same two
readings.
…m was fixed

`json_parse_reads_an_integer_wider_than_i64_as_digits` is the second of three cases arguing against
swapping `serde_yaml` for `serde_json` in `json_parse`, and its reason was that
`TryFrom<&serde_json::Value>`'s `is_u64` arm still held `num.as_u64().unwrap() as i64`, so the swap
would have reintroduced the sign flip. 72ff532 fixed that arm. The swap would no longer reintroduce
it, and the comment went on asserting that it would -- a stale claim of exactly the kind
`Value::try_from_json`'s own comment was corrected for in the same commit, which retired this reason
as its third and kept the other two. The test-file copy was missed.

Deleting the case was the alternative and is not taken: it is not only an argument about the swap.
`json_parse` converts with `Value::try_from_json`, so it shares `convert_yaml`'s `is_u64` arm with
the `TryFrom<&serde_yaml::Value>` entry point `values_tests.rs` covers -- measured, reverting that
arm fails both -- but that case stops at a `Value` while this one carries on through the
`PathAwareValue::try_from` `json_parse` calls next, and asserts the `PathAwareValue` a clause is
evaluated against. So it says a wide integer survives the whole of what `json_parse` does to it,
which is a reading no other case makes end to end.

The line reference the paragraph used to carry is a symbol name now, per the convention a1a4fa0 set.
…usion it names

200f324 added `the_known_loader_divergences_are_exactly_these_three` to stop the loader-divergence
exclusion list growing quietly. It does not, in the direction that matters. The spellings lived in two
places -- the `rstest` case attributes on `a_spelling_the_two_loaders_read_differently`, and a separate
`DIVERGENT` array -- and nothing connected them. Measured: adding a fourth divergent case to the
`rstest` and leaving `DIVERGENT` at three keeps the whole suite green, which is exactly the quiet
growth both comments say the pair prevents.

`assert_eq!(3, DIVERGENT.len())` could not catch it. On a `[&str; 3]` that compares two compile-time
constants, so growing the array without its declared length is a compile error and the assertion never
runs, and growing both fires it -- the direction someone doing the bookkeeping deliberately is already
in. Neither reaches the case list.

So there is one table now, holding each spelling with both of its readings, and one test over it. A
fourth row gets its two readings pinned, its divergence asserted, and its exclusion checked with no
second list to update. The cost is the `rstest` case names, which named each spelling in the output;
every assertion carries the scalar in its message instead. Keeping the `rstest` and having its cases
index the table was the alternative and is not taken: it leaves a row that no case reaches, which is
the same two-list problem one level down.

The test also claimed more than it checked. Its title said the spellings excluded from the agreement
document are exactly these three, and nothing looked at that document. It is a `const` now, and each
divergent spelling is asserted absent from it -- matched as a whole value after `": "` rather than as a
substring, so a spelling occurring inside a longer scalar is not misread as present. That closes the
half nobody covered: a spelling taken out of the agreement document and added to the exclusion list in
one commit used to fail nothing.

Every assertion is positive-controlled. Reverting `convert_yaml`'s `is_u64` arm fails the reading
assertions; a fourth row that does not diverge fails the `assert_ne!`; putting `0755` back into the
agreement document fails the exclusion assertion, and failed it with the scalar rendered, which an
`assert!` given a lone literal on edition 2018 does not do -- the first spelling of that message
printed `{scalar}`, and rustc's `non_fmt_panics` caught it.

The suite goes 3206 to 3200: four tests per binary become one, and `cfn-guard` builds two.
a59154c made a leading-zero decimal resolve as decimal and recorded the argument at the arm, but left
the paragraph above the function saying the old behavior. `resolve_int`'s doc comment claimed **a
decimal integer with a redundant leading zero stays a string**, so `0755` is the text "0755" -- while
the inline comment eighteen lines below it says `0755` is 755, and the code has no leading-zero branch
at all: `from_str_radix("0755", 10)` is 755. Measured through both loaders, the libyaml side gives 755.

The same sentence also said keeping the literal "is also what `serde_yaml` does", offering the arm as a
point of agreement between the two loaders. It is the opposite: this is one of the three spellings
`values_tests` pins as a divergence, and `serde_yaml` is the side that yields the string. So a reader
checking whether the loaders agree on `0755` found a claim that they do, in the function that decides
they do not.

The paragraph now states what the arm does and why refusing was rejected -- an unresolved scalar
becomes a `String` and disarms a numeric `when` gate silently -- and points at the arm for the full
argument rather than restating it. The rejected alternative is kept, because the 1.1-versus-1.2
ambiguity that motivated it is real and the next reader will meet it.

Two counts follow from the change. The header said "two deliberate departures" from 1.2 core; the
leading-zero arm is no longer one, since 1.2 core's decimal regex matches `0755`, so there is one
departure left -- the leading sign on the radix forms -- and two divergences from `serde_yaml`. The
closing paragraph said "one divergence ... is left standing on purpose" about `0b101`; it is now "the
other", so the two numbers agree.
… document shrinking

2d474b5 renamed `a_spelling_the_two_loaders_read_differently` and left two comments pointing at the
old name -- `values.rs`'s `is_u64` arm and `both_loaders_resolve_the_same_document_to_the_same_value`'s
own doc comment. Both are symbol references rather than line citations, which is the right style, but a
symbol reference to a symbol that no longer exists is the same defect the rename was fixing: a claim
maintained in two places, drifting. Nothing failed, because no gate reads a comment.

The same commit hoisted the agreement document into `AGREEMENT_DOCUMENT`, above the test rather than
below it, so "deliberately NOT in the document below" no longer described where anything was. It names
the const now.

The third repair is a direction neither the old bookkeeping nor 2d474b5 covered. Measured: deleting a
value from the agreement document and listing it nowhere leaves the whole suite green -- the agreement
test simply asserts less. `the_spellings_the_two_loaders_read_differently` cannot see it, because it
only checks that the three excluded spellings are absent, and a spelling removed from the document and
listed nowhere is absent from both. So the document's value count is asserted, at 40, measured rather
than declared.

Counted from the string at run time on purpose. The guard this branch just replaced was
`assert_eq!(3, DIVERGENT.len())` over a `[&str; 3]`, which compares two compile-time constants and can
therefore never move; `AGREEMENT_DOCUMENT.lines()` is computed from the document, so removing a line
drops the count. Positive-controlled in both directions: it passes at 40, and removing `hex: 0x1F`
fails it at 39.
@awsmadi

awsmadi commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for both of these — you reproduced two real defects, and I owe you an apology for the silence. Both were fixed the day after your review and nobody told you, so from your side this has looked like feedback into a void for three days.

JSON sign flip — fixed in 72ff532f. values.rs now gives the serde_json::Value arm the same digit-preserving treatment the YAML arm had. Regression goes through cfn_guard::run_checks as you asked, since that is the path Lambda and FFI callers take: a_json_integer_above_i64_max_keeps_its_digits_exactly in guard/tests/functional.rs. Reverting only that one line flips Size < 0 from FAIL back to PASS, so the test is not vacuous.

I also enumerated the class rather than just fixing the reported member. values.rs has three as_u64 sites: the two is_u64 arms, both now fixed, and number_key_name, which never had the cast. Repo-wide, is_u64() occurs at exactly those two places, and every other as i64 is float-to-int truncation that saturates rather than reinterpreting a bit pattern, each already range- or finiteness-guarded. Different class.

FFI error codes — fixed in 84478921 and d3c9d709. The header documents 1..=26, skipping 19, and both variants are in every_error_code_is_distinct_and_usable. Worth noting why the second commit exists: the distinctness test catches a plain collision but cannot see an identity swap, and the exact-value test catches the swap but not the collision. They are complements, so neither is redundant.

Verifying the above turned up four defects of our own, now fixed on this branch:

  • 64b5aefe — 72ff532f updated one copy of a now-false comment and left another in strings_tests.rs still claiming the JSON arm was unfixed.
  • 2d474b52 — a guard added to stop the loader-divergence set growing quietly was assert_eq!(3, DIVERGENT.len()) over a [&str; 3], i.e. assert_eq!(3, 3). Adding a case without bumping the declared length is a compile error, so the only edit that reached the assertion was one that grew both — the edit someone doing the bookkeeping deliberately has already made. The case list is gone; there is one list now.
  • 1ac75f7e — resolve_int's doc said a decimal with a redundant leading zero stays a string, while the code has no such branch and an inline comment eighteen lines below says otherwise. from_str_radix("0755", 10) is 755.
  • c8fe2ca7 — two references my own rename broke.

`resolve_int`'s contract carried "one deliberate departure from it, and two divergences from
`serde_yaml`". Both numbers were prose. Nothing computed either, so either could drift, and that
paragraph has drifted already: 1ac75f7 had to correct it after a59154c moved an arm, and the sentence
it corrected did not merely miscount -- it offered `0755` as a point of *agreement* with `serde_yaml`,
in the function that decides they disagree.

2d474b5 put the divergent spellings in one table, so how many there are is now a property of that
table. It is asserted there, in the test that reads it, and the contract names the table rather than
restating a total. The 1.2-core departure loses its numeral too: nothing tabulates departures from the
spec, so a count of those would be the same unchecked prose, and "a deliberate departure noted below"
says what is true without a number that can be wrong.

`DIVERGENT` becomes a slice, because otherwise the assertion means nothing. On
`[(&str, &str, &str); 3]` the length is declared beside the rows, so `assert_eq!(3, DIVERGENT.len())`
compares two compile-time constants -- which is exactly the guard 2d474b5 deleted, and exactly why it
never fired. A slice's length comes from its contents. Measured in both directions at three rows: a
fourth row fails it at 3 against 4, and deleting `0755` fails it at 3 against 2. Neither edit needs a
second one to compile, which is the whole difference from the array.

Removal is the direction the per-row assertions cannot see. Delete a row and every remaining row still
pins its two readings, still asserts its divergence, still checks its exclusion from the agreement
document -- all green, with one divergence no longer pinned anywhere. c8fe2ca closed that hole for
`AGREEMENT_DOCUMENT` by counting its values out of the string; this is the same repair on the sibling
table.

Two alternatives are not taken. Keeping the array and bumping its declared length leaves the assertion
unable to move for any edit that was not already deliberate, which is the defect rather than a fix for
it. Having the test read `loader.rs`'s doc comment and check the number written there would tie the two
directly, at the price of a doc-comment parser inside a test and a contract that can no longer be
reworded; naming the table is enough, because a reader who follows the name arrives at a number that is
checked.
`0b101` was named as a divergence in two contracts and asserted on one side. `case::binary` in
`an_integer_resolves_by_the_1_2_core_forms` pins this loader's reading -- the string `0b101` -- and
nothing anywhere read those characters through `serde_yaml`. A divergence with one side tested is half a
claim, and the untested half is the half that makes it a divergence rather than a preference: if
`serde_yaml` stopped resolving binary, the two loaders would agree, every assertion in the suite would
stay green, and both contracts would go on calling it a divergence.

Measured through the same path as the other rows: `serde_yaml` 0.9.34 resolves `0b101` to 5, which
`values.rs` renders as `5` against this loader's `0b101`. Positive-controlled by expecting `6` instead,
which fails on "the serde reading of `0b101` changed".

It is a row in `DIVERGENT` rather than a new mechanism, because a second list beside that table is the
defect 2d474b5 removed. Its arrival moves the size assertion from the previous commit to 4, which is
that assertion doing the only job it has.

The prose is reclassified rather than deleted. `0b101` stays under "closable, and left open with a
reason" -- the distinction between a divergence nothing here can close and one this loader declines to
close is real, and it is what tells the next reader whether to try -- but the category no longer ends at
"a choice, not a limit" with nothing checking either half. It points at the row, and the row is what
reports the choice being reversed: accepting binary makes the two loaders agree on `0b101` and fails the
`assert_ne!`. `resolve_int`'s contract now names the pinned readings and `case::binary` in the same
sentence that declines the extension.

The asymmetry inside the table is named in the per-spelling notes and not encoded. The other three rows
cannot be made to agree; this one could be. A fourth column saying which is which would be a field no
assertion reads, and this table exists because the last unread copy of its own contents was wrong.
…e captures"

This reverts commit 145e8ad.

The fix was extracted to aws-cloudformation#749, which carries a patch-identical commit, so it was
open in two places at once. Removing it here leaves aws-cloudformation#749 as the only one, where it
can be reviewed on its own instead of inside a 375-commit branch. aws-cloudformation#720 carries the
same revert.

Reverted rather than dropped from history: this branch is already pushed, and
rewriting it would need a force-push. A revert reaches the same resulting diff with
a fast-forward, and cancels out cleanly if this branch is squash-merged -- the squash
then contains neither the change nor its revert.

One conflict, in guard/tests/validate.rs, and it is pure adjacency. The reverted
commit appended its test at a point where later commits on this branch have since
appended 143 lines of their own, so the hunk no longer applied. Only the 21 lines
this commit added were removed; they were located as a contiguous block and compared
line for line against what the commit inserted rather than matched by eye, and they
sit at the end of the region, after everything later. The resulting change to the
file is 21 deletions and no insertions, the exact inverse of the insertion.

guard/src/rules/functions/strings.rs and guard/src/rules/functions/strings_tests.rs
auto-merged and are the exact inverse of what this commit did to them: +4/-27 and
0/-63 against its +27/-4 and +63/0. The `try_replacen` call goes back to the
`captures_iter`/`expand` loop under no comment, as it stood. The two later commits
that touch strings.rs on this branch, da15677 and b90613b, are both in
`json_parse` and are untouched.

The fixture guard/resources/validate/functions/rules/regex_replace_no_match.guard
goes with the test that reads it; nothing else names it.
@awsmadi

awsmadi commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favor of the smaller single-fix PRs the maintainers asked for. The work on this branch is being split into one PR per fix, submitted a few at a time in severity order; the first batch is #747, #748, #749, and #750. The branch stays on the fork unchanged as the integration reference, so anything here that has not yet been resubmitted is still reachable.

@awsmadi awsmadi closed this Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants