From 22c281e6f4d5737b46551eca5751e6d3ccb5dee1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 18 Sep 2026 12:01:27 +0200 Subject: [PATCH] feat(hydra-gates): a schema can declare that it carries no demo data buildiq's register holds the schemas the app keeps about itself: the apps it has built, their versions, the slug route index, the template store and the export jobs it has run. ADR-111 rule 1 asks for three demo objects per schema, so the generator wrote three of each, and on a demo instance that put three apps in the Apps list and on the dashboard that cannot be opened. Their applicationVersion rows carry the placeholder a format: uuid property gets (00000000-0000-4000-8000-000000000000) and a manifest with no pages, so the detail page renders empty and /apps/buildiq/builder// never resolves. Three more sat in the template store beside the four real built-ins, and three export-job rows read as finished exports nobody ran. Every one of those objects satisfies its schema, which is why --check was green on the dataset that broke the demo: conformance is about an object's shape, and nothing in a schema says whether its rows are content somebody authors or bookkeeping the app writes. So the app states it, the way a catalogue already does: "Application": { "x-openregister-demo-data": false, ... } A string is accepted in place of false and printed as the reason. true and absence both mean generate, which is what every schema in the fleet does today, so nothing changes for an app that adds no marker. Three places honour it: - build() generates nothing for the schema, and does so BEFORE --keep carries objects forward. An app adopting the marker regenerates an existing descriptor, and the objects it wants gone are exactly the ones --keep preserves. - check() SKIPs it instead of demanding three, so the gate cannot ask for the rows the app just removed. - check() FAILs when a mock descriptor still carries objects for it. Skipping alone would leave the broken rows in place for ever, and those rows are the defect. Six arms in test_generate_mock_register.py. Five go red with the generator reverted (5 failures); the sixth is the control that true and absence still generate, and is green both ways on purpose. --- .../scripts/lib/generate_mock_register.py | 90 ++++++++ .../lib/test_generate_mock_register.py | 195 ++++++++++++++++++ 2 files changed, 285 insertions(+) diff --git a/hydra-gates/scripts/lib/generate_mock_register.py b/hydra-gates/scripts/lib/generate_mock_register.py index 53fb6587..0b33163a 100644 --- a/hydra-gates/scripts/lib/generate_mock_register.py +++ b/hydra-gates/scripts/lib/generate_mock_register.py @@ -27,6 +27,12 @@ invites somebody to treat it as real. Apps that want curated, domain-true data for a headline schema override it — see `--keep`. +It also does not sample a schema the app keeps about ITSELF. A schema whose +rows the app writes as the record of an operation — the apps it has built, +the exports it has run — says so with `"x-openregister-demo-data": false` (or +a string reason) on its definition, and nothing is generated or demanded for +it. See `_excluded_schemas`. + USAGE generate-mock-register.py [--objects N] [--out FILE] [--check] @@ -777,6 +783,59 @@ def _catalogue_schemas(app_dir: str) -> set[str]: return catalogues +# --------------------------------------------------------------------------- +# A CONTROL-PLANE SCHEMA HAS NOTHING TO SAMPLE, AND SAMPLING IT SHIPS RUBBLE +# --------------------------------------------------------------------------- +# +# A catalogue is exempt because its rows already exist. This is the opposite +# case: rows that must NOT exist, because the app writes them itself as the +# record of an operation, and a generated one records an operation that never +# happened or points at an object that was never created. +# +# buildiq is the measured case. Its register holds the schemas buildiq keeps +# about itself — the apps it has built, their versions, the slug→app route +# index, the template store, the export jobs. Three generated objects per +# schema put three apps in the Apps list and on the dashboard that CANNOT BE +# OPENED: the matching `applicationVersion` rows carry +# `application: "00000000-0000-4000-8000-000000000000"` (the placeholder a +# `format: uuid` property gets) and a manifest with zero pages, so the detail +# page renders empty and `/apps/buildiq/builder//` never resolves. Three +# more landed in the template store beside the four real built-ins and would +# clone into an equally empty app, and three `export-job` rows appeared as +# finished exports nobody ran. Found on 2026-09-18 while recording a demo, on +# app `c5a2e155-432b-4ea8-a965-56661b991463`, slug `ccdc`. +# +# 🔴 EVERY ONE OF THOSE OBJECTS SATISFIES ITS SCHEMA. That is why `--check` was +# green on the dataset that broke the demo: conformance is about the object's +# shape, and nothing in a schema says whether its rows are content somebody +# authors or bookkeeping the app writes. So the app states it, the same way a +# catalogue does: +# +# "Application": { "x-openregister-demo-data": false, ... } +# +# A string is accepted in place of `false` and is printed as the reason, so the +# SKIP line says why rather than only that. `true` and absence both mean +# "generate", which is what every schema in the fleet already does. +def _excluded_schemas(app_dir: str) -> dict[str, str]: + """Schema names the app declares as carrying no demo data, and why. + + Read from every non-mock descriptor, so a schema defined apart from the + file that declares its register is still seen. + """ + excluded: dict[str, str] = {} + for _path, data in _component_files(app_dir): + block = _as_dict(data.get("components", {}).get("schemas")) + for name, sch in block.items(): + if not isinstance(sch, dict): + continue + declared = sch.get("x-openregister-demo-data", True) + if declared is False: + excluded.setdefault(name, "the app declares x-openregister-demo-data: false") + elif isinstance(declared, str) and declared.strip(): + excluded[name] = declared.strip() + return excluded + + def _descriptors(app_dir: str) -> list[tuple[str, dict]]: """The subset that DECLARES a register — the authority on register->schema. @@ -912,6 +971,7 @@ def build(app_dir: str, app_id: str, per_schema: int, existing: dict | None) -> _set_icon_scope(app_dir) decl_registers, owns, definitions = _register_schema_map(app_dir, app_id) catalogues = _catalogue_schemas(app_dir) + excluded = _excluded_schemas(app_dir) registers: dict[str, Any] = {} schemas: dict[str, Any] = {} @@ -936,6 +996,13 @@ def build(app_dir: str, app_id: str, per_schema: int, existing: dict | None) -> # `_catalogue_schemas`. Generating more duplicates them at import. if sch_name in catalogues: continue + # 🔴 BEFORE `keep`, DELIBERATELY. A schema that becomes excluded + # usually has objects in the descriptor already — they are the + # defect being removed. Carrying them because `--keep` was passed + # would leave regeneration unable to undo the thing it exists to + # undo, and `--keep` is how apps with curated data regenerate. + if sch_name in excluded: + continue sch = definitions[sch_name] schemas.setdefault(sch_name, _strip_code_refs(sch)) carried = keep.get((reg_slug, sch_name), []) @@ -1328,6 +1395,7 @@ def check(app_dir: str, app_id: str, per_schema: int, only: set[str] | None = No checked = failures = 0 catalogues = _catalogue_schemas(app_dir) + excluded = _excluded_schemas(app_dir) for reg_slug, sch_name in in_scope_pairs: sch = definitions.get(sch_name) checked += 1 @@ -1342,6 +1410,28 @@ def check(app_dir: str, app_id: str, per_schema: int, only: set[str] | None = No f"and demo data would duplicate them at import (ADR-111 rule 1 does not apply)." ) continue + + # A SCHEMA THE APP EXCLUDES IS EXEMPT FROM THE COUNT — AND FORBIDDEN + # FROM CARRYING ONE. Only skipping it would leave the objects that are + # already there in place for ever, and those objects ARE the defect: + # a control-plane row nobody created. See `_excluded_schemas`. + if sch_name in excluded: + stale = have.get(key, 0) + if stale > 0: + failures += 1 + print( + f"FAIL {app_id}: register '{reg_slug}' schema '{sch_name}' declares " + f"x-openregister-demo-data, so it must carry none — {stale} demo object(s) " + f"are still in a mock descriptor. Regenerate with " + f"`python3 vendor/conduction/hydra-gates/scripts/lib/generate_mock_register.py .`" + ) + continue + print( + f"SKIP {app_id}: register '{reg_slug}' schema '{sch_name}' carries no demo " + f"data — {excluded[sch_name]} (ADR-111 rule 1 does not apply)." + ) + continue + count = have.get(key, 0) if count < per_schema: failures += 1 diff --git a/hydra-gates/scripts/lib/test_generate_mock_register.py b/hydra-gates/scripts/lib/test_generate_mock_register.py index e190a9f1..edb377f0 100644 --- a/hydra-gates/scripts/lib/test_generate_mock_register.py +++ b/hydra-gates/scripts/lib/test_generate_mock_register.py @@ -35,6 +35,8 @@ """ from __future__ import annotations +import contextlib +import io import json import os import sys @@ -622,5 +624,198 @@ def test_a_mock_marked_file_is_skipped_by_discovery(self): self.assertNotIn("GhostSchema", definitions) +class ControlPlaneSchemasCarryNoDemoData(unittest.TestCase): + """A schema the app keeps about ITSELF must not be sampled. + + buildiq's register holds the apps it has built, their versions and the + exports it has run. Three generated objects per schema put three apps in + the Apps list that cannot be opened — their `applicationVersion` rows point + at `00000000-0000-4000-8000-000000000000` and carry a manifest with no + pages, so the detail page renders empty. + + 🔴 EVERY ONE OF THOSE OBJECTS SATISFIED ITS SCHEMA, which is why `--check` + was green on the dataset that broke the demo. Conformance cannot tell + content from bookkeeping, so the app declares it. + """ + + def _app_with_control_plane(self, root: Path, marker) -> None: + _write( + root, + "lib/Settings/widget_register.json", + { + "x-openregister": {"type": "application", "app": "widget"}, + "components": { + "registers": {"widget": {"schemas": ["Application", "HelloMessage"]}}, + "schemas": { + "Application": _schema( + {"name": {"type": "string"}}, + slug="built-app", + **{"x-openregister-demo-data": marker}, + ), + "HelloMessage": _schema( + {"message": {"type": "string"}}, slug="hello-message" + ), + }, + }, + }, + ) + + def _schemas_generated(self, root: Path) -> set[str]: + built = gmr.build(str(root), "widget", 3, None) + return {obj["@self"]["schema"] for obj in built["components"]["objects"]} + + def test_a_schema_declaring_false_gets_no_objects(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _app(root) + self._app_with_control_plane(root, False) + + generated = self._schemas_generated(root) + + # 🔴 THE ASSERTION THAT FAILS WITHOUT THE FEATURE: `built-app` was + # generated three times over, and those three rows are the defect. + self.assertNotIn("built-app", generated) + # And the control: the app's real content schema still generates, + # so an empty result cannot pass this arm by accident. + self.assertIn("hello-message", generated) + + def test_a_string_is_accepted_in_place_of_false(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _app(root) + self._app_with_control_plane(root, "buildiq writes these itself") + + self.assertNotIn("built-app", self._schemas_generated(root)) + + def test_true_and_absence_both_still_generate(self): + """The control arm: the exclusion must not leak to ordinary schemas.""" + for marker in (True, None): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _app(root) + if marker is None: + _write( + root, + "lib/Settings/widget_register.json", + { + "components": { + "registers": {"widget": {"schemas": ["Application"]}}, + "schemas": { + "Application": _schema( + {"name": {"type": "string"}}, slug="built-app" + ) + }, + } + }, + ) + else: + self._app_with_control_plane(root, marker) + + self.assertIn("built-app", self._schemas_generated(root), f"marker={marker!r}") + + def test_keep_does_not_carry_forward_the_objects_being_removed(self): + """`--keep` must not defeat the regeneration that removes them. + + An app adopting the marker regenerates an existing descriptor, and the + objects it wants gone are exactly the ones `--keep` preserves. + """ + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _app(root) + self._app_with_control_plane(root, False) + + existing = { + "components": { + "objects": [ + { + "@self": { + "register": "widget", + "schema": "built-app", + "slug": "built-app-voorbeeld-1", + }, + "name": "Voorbeeld Name 1", + } + ] + } + } + + built = gmr.build(str(root), "widget", 3, existing) + schemas = {obj["@self"]["schema"] for obj in built["components"]["objects"]} + + self.assertNotIn("built-app", schemas) + + def _check_output(self, root: Path) -> tuple[int, str]: + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + code = gmr.check(str(root), "widget", 3) + return code, buffer.getvalue() + + def test_check_skips_an_excluded_schema_instead_of_demanding_three(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _app(root) + self._app_with_control_plane(root, "buildiq writes these itself") + _write( + root, + "lib/Settings/widget_mock_register.json", + { + "x-openregister": {"type": "mock", "app": "widget"}, + "components": { + "objects": [ + { + "@self": { + "register": "widget", + "schema": "hello-message", + "slug": f"hello-{index}", + }, + "message": f"Voorbeeld Message {index}", + } + for index in range(3) + ] + }, + }, + ) + + code, output = self._check_output(root) + + # 🔴 WITHOUT THE FEATURE this is a FAIL: "`Application` has 0 demo + # object(s), needs 3" — the gate demanding the very rows the app + # just removed, which is how a fix gets reverted. + self.assertEqual(code, 0, output) + self.assertIn("SKIP", output) + self.assertIn("buildiq writes these itself", output) + + def test_check_fails_when_the_excluded_schema_still_carries_objects(self): + """Skipping alone would leave the broken rows in place for ever.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _app(root) + self._app_with_control_plane(root, False) + _write( + root, + "lib/Settings/widget_mock_register.json", + { + "x-openregister": {"type": "mock", "app": "widget"}, + "components": { + "objects": [ + { + "@self": { + "register": "widget", + "schema": "built-app", + "slug": "built-app-voorbeeld-1", + }, + "name": "Voorbeeld Name 1", + } + ] + }, + }, + ) + + code, output = self._check_output(root) + + self.assertEqual(code, 1, output) + self.assertIn("must carry none", output) + + if __name__ == "__main__": unittest.main(verbosity=2)