Skip to content

fix(gate-54): a correct nested relation was unrepresentable to the check (#231) - #255

Merged
rubenvdlinde merged 2 commits into
mainfrom
fix/gate-54-nested-relation-collector
Aug 8, 2026
Merged

fix(gate-54): a correct nested relation was unrepresentable to the check (#231)#255
rubenvdlinde merged 2 commits into
mainfrom
fix/gate-54-nested-relation-collector

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Closes #231. Supersedes #241 (that branch fixed only the false-positive half and shipped no runnable def test_; it is closed in favour of this one).

Cause

property_ids was built by a single non-recursive loop over schema.properties.*, while _raw_walk() recursed into the entire document and flagged every x-relation-filter whose containing dict was not in that set.

So a relation nested in items.properties.* was reported as "placed off a property" unconditionally. That is not a judgement, it is arithmetic — _raw_walk finds the node, the collector can never contain it, the finding always fires.

The finding was unfixable in the app. larpingapp's character.requirementOverrides.items.properties.skill is byte-for-byte the same shape as character.skills twenty lines above, which the gate accepts; the only difference is nesting depth. The three ways to clear it — move the filter off the property, flatten the array-of-objects, delete the filter — are all wrong.

The same blind spot ran the other way for checks (b), (d) and (f): a nested relation missing its $ref, carrying a dangling $ref, or carrying an invalid filter token was never inspected at all. The gate was simultaneously false-positive on shape and false-negative on the thing that matters.

Fix

One recursive collector, _collect_properties(), replaces the flat loop and feeds all the checks. It descends items.properties.* (both dict and tuple-items forms) and properties.* for inline objects, guarded by an id() seen-set and a depth cap — a crashed gate reports nothing, which reads exactly like a pass.

Three invariants deliberately preserved, each pinned by a test:

  • items is a subschema, not a property. A filter riding the items node is still a genuine rule-6 violation and is still reported.
  • @object.<field> still resolves against the ROOT schema at every depth. @object is the object under edit, not the array element. larpingapp's nested @object.setting points at character.setting; resolving against the element would have manufactured a fresh false positive.
  • Rule-10 (frozen lifecycle) stays top-level. A lifecycle field names a property of the schema, never an element of a nested array.

Acceptance — three proofs, all real runs

(a) True positive still caught. Never widen a checker until it catches nothing. Fixture with a filter on an x-* block and one inside items but not on a property — both still reported:

x-relation-filter at /components/schemas/character/properties/badFilterOnItems/items is placed off a property ...
x-relation-filter at /components/schemas/character/x-openregister-ui is placed off a property ...

(b) False positive gone. larpingapp@development, full-tree, all register files:

BEFORE (origin/main): 4 findings
AFTER  (this branch): 3 findings
removed: lib/Settings/larpingapp_register.json: x-relation-filter is placed off a property ...

The one removed line is the false positive. All three genuine findings survive.

(c) False negative closed. A nested relation with no $refsilent on origin/main, reported now:

character.missingRefNested.items.skill — relation-shaped property (format:uuid + relation description) lacks canonical $ref (ADR-062 rule 7)

Finding count vs defect count

Gate-53 turned ~132 defects into "240 violations" because one missing _note emitted a triplet, so this helper was audited for the same shape. Exactly one overlap: a property with x-relation-filter and no $ref matched check (b) and check (c) — one defect, one fix, two messages. Check (c)'s duplicate is now suppressed when (b) already reported that property, so the ratio is 1:1. Check (d) stays per-token by design (three bad tokens are three defects).

Placement findings now name the node

They named only the FILE. A register emitting three of them gave the reader three identical lines for three different nodes — that ambiguity is exactly what made #231 hard to triage. They now carry a JSON pointer.

Fleet re-measure (19 apps' registers, read-only)

The placement false positive existed only on larpingapp. Closing the false negative surfaces 8 previously-silent nested missing-$ref findings: scholiq 4, decidesk / hermiq / openbuild / openconnector 1 each. Check (b) is property-level diff-scoped in CI, so these only surface on a PR that touches those lines — no repo goes red on landing.

Honest caveat: of those 8, at least two (hermiq CourseRecommendation.recommendations.items.courseId, openconnector source...credentialRef.credentialId) are cross-register FKs where a local $ref cannot resolve, so they are not fixable by adding one. That is a pre-existing weakness of check (b)'s prose heuristic which this change gives more reach; it is filed separately rather than patched under this issue with a prose carve-out.

Tests

New suite test_check_relation_dialect.py, auto-discovered by run-helper-suites.sh: 0 → 24 tests. Full suite on the rebased tree: 29 passed, 2 quarantined, 0 failed.

Every new test mutation-checked (each mutation applied, suite run, mutation reverted, suite re-run green):

mutation tests that went red
disable the items recursion 10
collect items itself as a property 2 (both true-positive guards)
remove the (b)/(c) dedupe 1
remove the depth == 0 lifecycle guard 1
remove the depth cap 2
anti-widening control — collector accepts everything 4 (all 3 true-positive tests)
whole suite vs origin/main's helper 16 of 24

Touches only check_relation_dialect.py and its new test file.

Conduction Release Bot added 2 commits August 8, 2026 15:45
…eck (#231)

`property_ids` was built by a single non-recursive loop over
`schema.properties.*` while `_raw_walk` recursed into the whole document.
Every relation nested inside `items.properties.*` was therefore reported as
"x-relation-filter is placed off a property" UNCONDITIONALLY — the collector
could never contain it, so the finding was not a judgement, it was arithmetic.

The finding was unfixable in the app. larpingapp's
`character.requirementOverrides.items.properties.skill` is byte-for-byte the
same shape as `character.skills` twenty lines above, which the gate accepts;
the only difference is nesting depth. The three ways to clear it (move the
filter off the property, flatten the array-of-objects, delete the filter) are
all wrong.

The same blind spot ran in the opposite direction for checks (b), (d) and (f):
a nested relation MISSING its `$ref`, carrying a dangling `$ref` or an invalid
filter token was never inspected. The gate was simultaneously false-positive on
shape and false-negative on the thing that matters.

One recursive collector (`_collect_properties`) fixes both directions. It
descends `items.properties.*` (dict and tuple `items` forms) and `properties.*`
for inline objects, guarded by an id() seen-set and a depth cap so a
pathological document cannot crash the gate — a crashed gate reports nothing,
which reads exactly like a pass.

Invariants deliberately preserved, each pinned by a test:
  * `items` is a subschema, NOT a property — a filter riding on the items node
    is still a real rule-6 violation and is still reported.
  * `@object.<field>` still resolves against the ROOT schema at every depth.
    `@object` is the object under edit, not the array element; larpingapp's
    nested `@object.setting` points at `character.setting`. Resolving against
    the element would have manufactured a fresh false positive.
  * rule-10 (frozen lifecycle) stays top-level: a lifecycle `field` names a
    property of the schema, never an element of a nested array.

Also, because a finding count is not a defect count (gate-53 turned ~132
defects into "240 violations"): audited this helper for multi-emission. Exactly
one overlap — a property with `x-relation-filter` and no `$ref` matched check
(b) AND check (c), one defect stated twice with one fix. Check (c)'s duplicate
is now suppressed when (b) already reported that property, so the ratio is 1:1.
Check (d) stays per-token by design.

And the placement findings now carry a JSON pointer. They named only the FILE,
so a register emitting three of them gave the reader three identical lines for
three different nodes — that ambiguity is what made this issue hard to triage.

Measured, larpingapp@development registers (full-tree, no diff scope):
  before 4 findings / after 3 — the false positive is gone and all three
  genuine findings survive.

Fleet-wide re-measure over 19 apps' registers: the placement false positive
existed only on larpingapp; the closed false negative surfaces 8 previously
silent nested missing-$ref findings (scholiq 4, decidesk/hermiq/openbuild/
openconnector 1 each). Check (b) is property-level diff-scoped in CI, so these
only surface on a PR that touches those lines.

Tests: new suite, 0 -> 23 tests, auto-discovered by run-helper-suites.sh
(28 suites passing, 2 quarantined). Every new test mutation-checked: reverting
the items recursion fails 10, collecting `items` as a property fails 2 (the
true-positive guards), removing the dedupe fails 1, removing the top-level
guard fails 1, and the whole suite run against origin/main's helper fails 16.
Past the bound a nested property is no longer collected, so its filter is
reported as misplaced — an over-report a human can dismiss. Silently accepting
everything past the bound is the dangerous direction and looks exactly like a
pass. Carried over from the earlier attempt on this issue, which asserted the
same property.
@rubenvdlinde
rubenvdlinde merged commit 6e8db5e into main Aug 8, 2026
29 checks passed
@rubenvdlinde
rubenvdlinde deleted the fix/gate-54-nested-relation-collector branch August 8, 2026 13:49
rubenvdlinde pushed a commit that referenced this pull request Aug 8, 2026
Both hydra-gates-only; no overlap with this branch's five files.
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.

gate-54 relation-dialect: property_ids never includes items.properties.* — a correct nested relation is reported as misplaced

1 participant