Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions hydra-gates/scripts/lib/check_relation_dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,33 @@ def _global_schema_keys(paths):
_RELATION_DESC_RE = re.compile(r"\b(reference to|verwijzing naar|uuid of the|fk to)\b", re.I)


def _is_external_ref(prop):
"""True when a property points at a schema in ANOTHER app's register.

OpenRegister resolves ``$ref`` inside ONE register set, so a schema owned by
a different app is not expressible as a relation at all. Registers mark such
properties with ``x-external-register: <app>`` and carry the bare identifier
(``type: string`` + ``format: uuid``).

Without this notion the gate was UNCLOSABLE for a genuine cross-app
reference — measured 2026-08-09 on docudesk, whose `correspondence.
caseReference` and `generatedDocument.zaakId` point at a Zaak in Procest:

WITH ``"$ref": "case"`` -> check (f) "does not resolve to a schema key
in the register set (case-exact)"
WITHOUT ``$ref`` -> check (b) "relation-shaped property ...
lacks canonical $ref (ADR-062 rule 7)"

Both arms failed, so the only route to green was rewording the description
until ``_RELATION_DESC_RE`` stopped matching — degrading documentation to
dodge a regex. The exemption is deliberately narrow: it suppresses ONLY the
two ``$ref`` rules, and a property that declares ``x-external-register``
*and* a ``$ref`` is still reported, because that ``$ref`` is one
OpenRegister can never resolve.
"""
return isinstance(prop, dict) and bool(str(prop.get("x-external-register") or "").strip())


def _ref_of(prop):
"""Return (raw_ref_value, is_array) for a relation property, or (None,_).

Expand Down Expand Up @@ -488,7 +515,10 @@ def check_file(path, keys, findings, base_ref):
in_diff = changed is None or pline in changed

# (b) relation-shape heuristic — property-level diff scoped.
if in_diff and _has_uuid_format(prop) and not _is_relation_prop(prop):
# A cross-app identifier is exempt: there is no local schema key it
# could ever name (see _is_external_ref).
if (in_diff and _has_uuid_format(prop) and not _is_relation_prop(prop)
and not _is_external_ref(prop)):
desc = prop.get("description") or ""
items = prop.get("items")
if isinstance(items, dict):
Expand Down Expand Up @@ -551,7 +581,19 @@ def check_file(path, keys, findings, base_ref):

# (f) $ref target resolution.
ref, _is_arr = _ref_of(prop)
if ref is not None:
if ref is not None and _is_external_ref(prop):
# Still a defect — but the actionable fix is the opposite of the
# generic message. OpenRegister cannot resolve a schema in
# another app's register, so the $ref is dead weight and the
# bare identifier is the correct dialect.
findings.append((path, (
f"{path}: {sname}.{pname} — carries x-external-register "
f"'{prop.get('x-external-register')}' AND $ref '{ref}'; "
f"OpenRegister resolves $ref within one register set and "
f"cannot reach another app's schema — drop the $ref and keep "
f"the bare identifier (ADR-062 rule 7)"
)))
elif ref is not None:
verdict, norm = _resolve_ref(ref, keys)
if verdict == "fail":
findings.append((path, (
Expand Down
75 changes: 75 additions & 0 deletions hydra-gates/scripts/lib/test_check_relation_dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,81 @@ def test_inert_filter_alone_is_still_reported(self):
f"inert filter must still be reported, got {msgs}")


# --------------------------------------------------------------------------
# 5b. Cross-app references — the gate must be CLOSABLE.
#
# Measured 2026-08-09 on docudesk (gate package e7bde0a): two properties
# reference a Zaak/case that lives in Procest's register, not DocuDesk's, and
# say so with `x-external-register: procest`. There was no way to author them:
#
# WITH "$ref": "case" -> "$ref 'case' does not resolve to a schema key
# in the register set (case-exact)" (f)
# WITHOUT "$ref" -> "relation-shaped property (format:uuid +
# relation description) lacks canonical $ref" (b)
#
# Both arms fail, so the only way to reach green was to reword the description
# until `_RELATION_DESC_RE` stopped matching — degrading documentation to dodge
# a regex, which is exactly what a gate must never reward. `x-external-register`
# appeared ZERO times in this checker: it had no concept of a cross-app
# reference at all.
#
# The rule these tests pin: OpenRegister resolves `$ref` inside ONE register
# set, so a foreign schema is not expressible as a relation. A property that
# declares `x-external-register` is therefore a plain identifier — it must NOT
# carry a `$ref`, and it must not be asked for one.
# --------------------------------------------------------------------------
class CrossAppReferenceTest(_Base):
EXTERNAL = {
"type": "string",
"format": "uuid",
"description": "UUID of the source Zaak (case) in Procest",
"x-external-register": "procest",
"title": "Case reference",
}

def test_external_reference_without_ref_is_accepted(self):
"""The correct authoring must PASS — this is the closable arm."""
doc = _register({"caseReference": dict(self.EXTERNAL)})
msgs = self.run_check(doc)
self.assertEqual(
msgs, [],
"a cross-app identifier carrying x-external-register and no $ref is "
f"correctly authored and must not be reported, got {msgs}",
)

def test_external_reference_with_a_dangling_ref_is_still_reported(self):
"""The true positive must SURVIVE. A `$ref` OpenRegister cannot resolve
is still wrong — the fix is to drop it, and the message must say so
rather than repeat the generic 'does not resolve'."""
prop = dict(self.EXTERNAL)
prop["$ref"] = "case"
msgs = self.run_check(_register({"caseReference": prop}))
self.assertEqual(len(msgs), 1, f"expected exactly one finding, got {msgs}")
self.assertIn("x-external-register", msgs[0])
self.assertIn("drop the $ref", msgs[0])

def test_a_local_dangling_ref_is_unaffected(self):
"""Control: without x-external-register, a dangling $ref keeps its
original message. Widening a checker until it catches nothing is not a
fix."""
msgs = self.run_check(_register({"badRel": {
"type": "string",
"format": "uuid",
"$ref": "nosuchschema",
"title": "Bad rel",
}}))
self.assertEqual(len(msgs), 1, f"expected exactly one finding, got {msgs}")
self.assertIn("does not resolve", msgs[0])

def test_external_marker_does_not_excuse_a_bad_filter_token(self):
"""Control: the exemption is scoped to the $ref rules only."""
prop = dict(self.EXTERNAL)
prop["x-relation-filter"] = {"setting": "@bogus"}
msgs = self.run_check(_register({"caseReference": prop}))
self.assertTrue(any("unknown token" in m for m in msgs),
f"filter validation must still apply, got {msgs}")


# --------------------------------------------------------------------------
# 6. End-to-end through main() — findings land in the log file, not stdout.
# --------------------------------------------------------------------------
Expand Down
Loading