diff --git a/hydra-gates/scripts/lib/check_relation_dialect.py b/hydra-gates/scripts/lib/check_relation_dialect.py index dc9c7814..7423b0cf 100644 --- a/hydra-gates/scripts/lib/check_relation_dialect.py +++ b/hydra-gates/scripts/lib/check_relation_dialect.py @@ -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: `` 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,_). @@ -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): @@ -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, ( diff --git a/hydra-gates/scripts/lib/test_check_relation_dialect.py b/hydra-gates/scripts/lib/test_check_relation_dialect.py index 5d04e308..8a0332ce 100644 --- a/hydra-gates/scripts/lib/test_check_relation_dialect.py +++ b/hydra-gates/scripts/lib/test_check_relation_dialect.py @@ -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. # --------------------------------------------------------------------------