From a66f8ba291a943a7fb3b00baa5dddcc2a1241aae Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 12 Oct 2026 11:23:56 +0200 Subject: [PATCH] feat(hydra-gates): gate 109 verifies a seed slug owned by a sibling app A seed may require a schema slug another app owns, named on its own line: # hydra-gate-109 external: owner= The gate checks the claim: the owner must be in the caller's additional-apps and its descriptors must declare the slug. An owner it cannot read fails and is named. quality.yml checks the caller's additional-apps out for the gates job (read-only token, no credentials persisted). --- .github/workflows/quality.yml | 49 +++++ .../scripts/lib/check_seed_required_slugs.py | 154 +++++++++++++- .../lib/test_check_seed_required_slugs.py | 190 +++++++++++++++++- hydra-gates/scripts/run-hydra-gates.sh | 9 +- 4 files changed, 392 insertions(+), 10 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 4f6ed735f..4c9d2e7b8 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -8047,12 +8047,19 @@ jobs: # which is how a PR keeps its mechanical feedback while someone fixes an # unrelated Playwright job. timeout-minutes: 20 + # READ-ONLY TOKEN, AND NO TOKEN ON DISK. This job now clones the caller's + # `additional-apps` (below) so gate 109 can verify a slug an app borrows + # from a sibling. Same rule as every other job that touches those repos + # (keepiq#881), asserted by scripts/assert-sibling-jobs-read-only-token.py. + permissions: + contents: read steps: - name: Checkout app uses: actions/checkout@v4 with: path: app + persist-credentials: false # fetch-depth: 0 is REQUIRED, not an optimisation. The gates are # diff-scoped; a shallow clone has no base branch to diff against, so # the base would not resolve and the run would (correctly) exit 99. @@ -8064,6 +8071,44 @@ jobs: repository: ConductionNL/.github ref: ${{ inputs.hydra-gates-ref }} path: gates + persist-credentials: false + + # ── Sibling apps, so a slug an app BORROWS can be verified ──────────── + # + # Gate 109 lets a seed require a schema slug another app owns, but only + # on the seed's say-so PLUS the owner's descriptors actually declaring it + # (`# hydra-gate-109 external: owner=`). This job checks out + # one repository, so without this step the gate could not read the owner + # and would have to either trust the annotation or fail every time. + # + # It clones the SAME lists, at the SAME refs, that the phpunit and + # browser jobs install, so "the owner declares it" is judged against the + # code the seed really runs next to. + # + # A clone that fails does NOT fail this step and is NOT retried: the gate + # fails, naming the owner and the directory it could not read, and only + # when an annotation depends on it. A failure here for an app whose seed + # borrows nothing would be a red build about nothing. + - name: Check out additional apps for gate 109 + env: + ADDITIONAL_APPS: ${{ inputs.additional-apps }} + E2E_ADDITIONAL_APPS: ${{ inputs.e2e-additional-apps }} + run: | + set -u + SIB="${RUNNER_TEMP}/gate-siblings" + mkdir -p "${SIB}" + printf '%s\n%s\n' "${ADDITIONAL_APPS}" "${E2E_ADDITIONAL_APPS}" \ + | jq -c -s 'add // [] | .[]' | while read -r app; do + repo=$(echo "$app" | jq -r '.repo') + name=$(echo "$app" | jq -r '.app') + ref=$(echo "$app" | jq -r '.ref // "main"') + if git clone --depth 1 --branch "$ref" "https://github.com/$repo.git" "${SIB}/$name"; then + echo "gate 109: read $repo ($name) at $ref" + else + echo "::warning::gate 109: could not clone $repo ($name) at $ref. A seed annotation naming $name as owner will FAIL, naming this directory." + fi + done + echo "HYDRA_GATE_SIBLINGS_DIR=${SIB}" >> "$GITHUB_ENV" # ── Does the PINNED gate package know what "not applicable" means? ───── # @@ -8459,6 +8504,10 @@ jobs: # from the caller's declaration rather than from whatever the runner # defaults to. HYDRA_GATE_SPEC_ANCHOR_E2E_BLOCKING: ${{ inputs.hydra-gates-e2e-anchor-blocking && '1' || '0' }} + # Gate 109: the apps this caller's CI installs (both lists, as two + # JSON arrays). Passed even when both are `[]`, so "installs nothing" + # and "was not told" stay different answers. + HYDRA_GATE_ADDITIONAL_APPS: "[${{ inputs.additional-apps }},${{ inputs.e2e-additional-apps }}]" run: | set -u ARGS="--app-dir ${GITHUB_WORKSPACE}/app" diff --git a/hydra-gates/scripts/lib/check_seed_required_slugs.py b/hydra-gates/scripts/lib/check_seed_required_slugs.py index 3e75616de..7a2bbb3e4 100644 --- a/hydra-gates/scripts/lib/check_seed_required_slugs.py +++ b/hydra-gates/scripts/lib/check_seed_required_slugs.py @@ -43,11 +43,33 @@ in both directions. * It does not look at registers, only schemas. The register list is a different shape and a different failure. -* A slug another app owns is gate 106's subject, not this one's. A seed may - legitimately require a schema a sibling app declares — buildiq's export - bundler reads hermiq's `agent` on purpose — so a slug this app does not - declare is reported only when the app declares NOTHING resembling it. See - `_unresolved` for the exact rule. +* Whether an app SHOULD own a slug. That is gate 106's subject. This gate + only asks that the seed's requirement can be met. + +A SLUG ANOTHER APP OWNS: THE ANNOTATION, AND THE CLAIM IS VERIFIED + +A seed may legitimately require a schema a sibling app declares (buildiq's +export bundler reads hermiq's `agent`; since decision 196 buildiq's seed reads +openregister's `decision-table`). The seed says so on a line of its own: + + # hydra-gate-109 external: decision-table owner=openregister + +The same `hydra-gate- :` shape as the `hydra-gate-98 held:` marker in +info.xml. An annotation is a CLAIM, and the gate checks it rather than trusting +it: + + * the owner must be in the app's CI `additional-apps` / `e2e-additional-apps` + (the workflow hands the gate that list as HYDRA_GATE_ADDITIONAL_APPS); + * the owner's descriptors, read from the checkout the workflow made at the ref + it installs (HYDRA_GATE_SIBLINGS_DIR/), must really declare the slug, + by the same merged-view resolution used for the app's own; + * if any of that cannot be read the gate FAILS and names what it could not + read. A check that did not run looks exactly like one that passed. + +An annotation nothing in the seed needs (the slug is not required, or the app +declares it itself) is reported as STALE. That is a warning, not a failure. +A `hydra-gate-109` line that does not parse is a failure: a typo in the +directive must not turn into an unchecked requirement. Exit codes: 0 pass, 1 findings, 2 usage/wiring. """ @@ -57,6 +79,9 @@ import re import sys +ENV_APPS = 'HYDRA_GATE_ADDITIONAL_APPS' +ENV_SIBLINGS = 'HYDRA_GATE_SIBLINGS_DIR' + SEED = os.path.join('tests', 'e2e', 'ci-seed.sh') # The required-schema list, as every app in the fleet spells it: @@ -167,6 +192,88 @@ def _required_slugs(app_dir): return [m for m in _SLUG_RE.findall(match.group('body'))] +# `# hydra-gate-109 external: owner=` +_ANNOTATION_RE = re.compile( + r"^\s*#\s*hydra-gate-109\s+external:\s*(?P[A-Za-z0-9_.\-]+)" + r"\s+owner=(?P[A-Za-z0-9_.\-]+)\s*$" +) +_DIRECTIVE_RE = re.compile(r"^\s*#\s*hydra-gate-109\b") + + +def _annotations(app_dir): + """(claims, malformed): claims is a list of (slug, owner, lineno). + + Malformed lines are returned, never skipped: a misspelt directive that is + silently ignored leaves the slug as an unannotated finding at best and, at + worst, an annotation nobody checks. + """ + path = os.path.join(app_dir, SEED) + claims, malformed = [], [] + if not os.path.isfile(path): + return claims, malformed + with open(path, encoding='utf-8') as fh: + for n, line in enumerate(fh, 1): + if not _DIRECTIVE_RE.match(line): + continue + m = _ANNOTATION_RE.match(line.rstrip('\n')) + if m: + claims.append((m.group('slug'), m.group('owner'), n)) + else: + malformed.append((n, line.strip())) + return claims, malformed + + +def _installed_apps(): + """Names in the CI additional-apps lists, or None when not supplied. + + None and [] are different answers and must stay different: None means the + gate was not told, so it cannot say whether the owner is installed. + """ + raw = os.environ.get(ENV_APPS) + if raw is None or not raw.strip(): + return None + try: + doc = json.loads(raw) + except ValueError: + return None + names = set() + # The workflow passes the two lists concatenated; accept either a flat + # array or an array of arrays. + stack = [doc] + while stack: + item = stack.pop() + if isinstance(item, list): + stack.extend(item) + elif isinstance(item, dict) and item.get('app'): + names.add(str(item['app'])) + return names + + +def _verify_claim(slug, owner, installed): + """None when the owner really declares `slug`, else why it does not.""" + if installed is None: + return (f"cannot verify owner '{owner}': {ENV_APPS} was not provided, " + f"so the gate does not know what this app's CI installs") + if owner not in installed: + return (f"owner '{owner}' is not in this app's CI additional-apps / " + f"e2e-additional-apps, so it is not installed when the seed runs") + base = os.environ.get(ENV_SIBLINGS, '') + if not base: + return (f"cannot read owner '{owner}': {ENV_SIBLINGS} was not provided " + f"(the workflow checks installed apps out there)") + owner_dir = os.path.join(base, owner) + if not os.path.isdir(owner_dir): + return f"cannot read owner '{owner}': {owner_dir} does not exist" + declared = _declared_slugs(owner_dir) + if not declared: + return (f"cannot read owner '{owner}': no descriptor under {owner_dir} " + f"(lib/Settings, tests/e2e, appinfo) declares any schema") + if slug.lower() not in {d.lower() for d in declared}: + return (f"owner '{owner}' declares {len(declared)} schema slug(s) and " + f"none is '{slug}'") + return None + + def _unresolved(required, declared): """Required slugs this app declares nothing for. @@ -197,17 +304,52 @@ def main(): return 2 findings = _unresolved(required, declared) + claims, malformed = _annotations(args.app_dir) + lowered_local = {d.lower() for d in declared} + lowered_required = {r.lower() for r in required} + installed = _installed_apps() if claims else None + + external = 0 + extra = [] + for n, text in malformed: + extra.append(f"FAIL {SEED}:{n}: unparseable hydra-gate-109 directive " + f"'{text}'. Expected " + f"'# hydra-gate-109 external: owner='.") + claimed = set() + for slug, owner, n in claims: + low = slug.lower() + if low not in lowered_required: + print(f"STALE {SEED}:{n}: annotation for '{slug}' " + f"(owner={owner}) but the seed does not require it.") + continue + if low in lowered_local: + print(f"STALE {SEED}:{n}: annotation for '{slug}' " + f"(owner={owner}) but this app declares it itself.") + continue + claimed.add(low) + why = _verify_claim(slug, owner, installed) + if why is None: + external += 1 + else: + extra.append(f"FAIL {SEED}:{n}: annotation '{slug}' " + f"owner={owner}: {why}.") + + # An annotated slug is judged by its claim above, not by the local list. + findings = [f for f in findings if f.lower() not in claimed] for slug in findings: print(f"FAIL {SEED}: requires schema slug '{slug}', which no " f"descriptor under lib/Settings declares. The seed exits before " f"Playwright starts, so every spec would report as NOT RUN.") + for line in extra: + print(line) + findings = findings + extra # 🔴 A TERMINAL SUMMARY, SO A CRASH IS NOT READ AS A FINDING. The runner # checks for this line before believing a non-zero exit (.github#374): a # checker that dies half way through otherwise reports as a clean failure # with however many findings it managed to print. print(f'checked {len(required)} required slug(s) against ' - f'{len(declared)} declared') + f'{len(declared)} declared ({external} via external owner)') if args.mode == 'report': return 0 diff --git a/hydra-gates/scripts/lib/test_check_seed_required_slugs.py b/hydra-gates/scripts/lib/test_check_seed_required_slugs.py index cf7b7d0a8..40066baf1 100644 --- a/hydra-gates/scripts/lib/test_check_seed_required_slugs.py +++ b/hydra-gates/scripts/lib/test_check_seed_required_slugs.py @@ -65,10 +65,19 @@ def _tree(tmp, schemas_by_file, required, base=('lib', 'Settings')): return tmp -def _run(app_dir): +def _run(app_dir, apps=None, siblings=None): + """Run the checker. `apps` / `siblings` set the two CI hand-off variables; + left None they are REMOVED from the environment, so a developer's shell + cannot make a test pass.""" + env = {k: v for k, v in os.environ.items() + if k not in ('HYDRA_GATE_ADDITIONAL_APPS', 'HYDRA_GATE_SIBLINGS_DIR')} + if apps is not None: + env['HYDRA_GATE_ADDITIONAL_APPS'] = apps + if siblings is not None: + env['HYDRA_GATE_SIBLINGS_DIR'] = siblings proc = subprocess.run( [sys.executable, CHECKER, app_dir], - capture_output=True, text=True, check=False, + capture_output=True, text=True, check=False, env=env, ) return proc.returncode, proc.stdout @@ -189,6 +198,174 @@ def test_seed_without_descriptors_is_wiring_not_pass(): check('no descriptors reports wiring', rc == 2, f'rc={rc} {out}') +# --------------------------------------------------------------------------- +# External owner annotation. Every arm that expects a FAIL has a control that +# expects a PASS on the same tree with ONE thing changed, so a pass cannot be +# the annotation being ignored and a fail cannot be an unrelated error. +# --------------------------------------------------------------------------- +ANNOTATION = '# hydra-gate-109 external: %s owner=%s\n' +APPS = '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"development"}]' + + +def _app_with_annotation(tmp, required, annotations): + _tree(tmp, {'app_register.json': {'Task': {'slug': 'task'}}}, required) + seed = os.path.join(tmp, 'tests', 'e2e', 'ci-seed.sh') + with open(seed, encoding='utf-8') as fh: + body = fh.read() + with open(seed, 'w', encoding='utf-8') as fh: + fh.write(body + ''.join(ANNOTATION % a for a in annotations)) + + +def _owner(sib, slugs, name='openregister'): + path = os.path.join(sib, name, 'lib', 'Settings', 'reg.json') + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'w', encoding='utf-8') as fh: + json.dump({'components': {'schemas': {s: {'slug': s} for s in slugs}}}, fh) + + +def test_owner_declares_it_passes_and_misspelling_fails(): + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as sib: + _app_with_annotation(tmp, ['task', 'decision-table'], + [('decision-table', 'openregister')]) + _owner(sib, ['decision-table', 'other']) + rc, out = _run(tmp, APPS, sib) + check('owner declares the slug: passes', rc == 0, f'rc={rc} {out}') + check('and says one was resolved externally', + '1 via external owner' in out, out) + + # CONTROL: the same tree, the owner declares a near-miss (typo in slug). + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as sib: + _app_with_annotation(tmp, ['task', 'decision-table'], + [('decision-table', 'openregister')]) + _owner(sib, ['decision-tabel']) + rc, out = _run(tmp, APPS, sib) + check('owner does not declare the slug: fails', rc == 1, f'rc={rc} {out}') + check('and names slug and owner', + "'decision-table'" in out and "owner 'openregister'" in out, out) + + # CONTROL: the typo is in the SEED annotation, not the owner. + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as sib: + _app_with_annotation(tmp, ['task', 'decision-table'], + [('decision-tabel', 'openregister')]) + _owner(sib, ['decision-table']) + rc, out = _run(tmp, APPS, sib) + check('misspelt annotation slug fails', rc == 1, f'rc={rc} {out}') + check('the required slug is reported unresolved', + "requires schema slug 'decision-table'" in out, out) + check('the unused annotation is reported stale', 'STALE' in out, out) + + +def test_owner_unreadable_fails_naming_what_was_missing(): + cases = ( + ('no siblings dir given', 'none', 'HYDRA_GATE_SIBLINGS_DIR'), + ('owner dir absent', 'empty', 'does not exist'), + ('owner dir has no descriptors', 'bare', 'no descriptor'), + ) + for label, mode, needle in cases: + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as sib: + _app_with_annotation(tmp, ['task', 'decision-table'], + [('decision-table', 'openregister')]) + if mode == 'bare': + os.makedirs(os.path.join(sib, 'openregister')) + rc, out = _run(tmp, APPS, None if mode == 'none' else sib) + check(f'unreadable owner fails ({label})', rc == 1, f'rc={rc} {out}') + check(f'and names what it could not read ({label})', needle in out, out) + + # CONTROL for all three: make the owner readable and the same tree passes. + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as sib: + _app_with_annotation(tmp, ['task', 'decision-table'], + [('decision-table', 'openregister')]) + _owner(sib, ['decision-table']) + rc, out = _run(tmp, APPS, sib) + check('control: readable owner passes', rc == 0, f'rc={rc} {out}') + + +def test_additional_apps_not_provided_fails_not_passes(): + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as sib: + _app_with_annotation(tmp, ['task', 'decision-table'], + [('decision-table', 'openregister')]) + _owner(sib, ['decision-table']) + rc, out = _run(tmp, None, sib) + check('additional-apps list not provided fails', rc == 1, f'rc={rc} {out}') + check('and names the variable', 'HYDRA_GATE_ADDITIONAL_APPS' in out, out) + + +def test_owner_not_installed_fails(): + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as sib: + _app_with_annotation(tmp, ['task', 'decision-table'], + [('decision-table', 'openregister')]) + _owner(sib, ['decision-table']) # on disk, but not installed by CI + other = '[{"repo":"ConductionNL/integriq","app":"integriq"}]' + rc, out = _run(tmp, other, sib) + check('owner absent from additional-apps fails', rc == 1, f'rc={rc} {out}') + check('and says it is not installed', + 'not in this app\'s CI additional-apps' in out, out) + rc, out = _run(tmp, '[]', sib) + check('an EMPTY additional-apps list also fails', rc == 1, f'rc={rc} {out}') + # CONTROL: e2e list concatenated as an array of arrays still counts. + rc, out = _run(tmp, '[' + other + ',' + APPS + ']', sib) + check('control: owner present among several passes', rc == 0, + f'rc={rc} {out}') + + +def test_stale_annotation_is_reported_not_failed(): + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as sib: + _app_with_annotation(tmp, ['task'], [('decision-table', 'openregister')]) + _owner(sib, ['decision-table']) + rc, out = _run(tmp, APPS, sib) + check('annotation nothing requires is reported STALE', 'STALE' in out + and 'decision-table' in out, out) + check('stale alone does not fail', rc == 0, f'rc={rc} {out}') + # CONTROL: a required, claimed slug is not stale. + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as sib: + _app_with_annotation(tmp, ['task', 'decision-table'], + [('decision-table', 'openregister')]) + _owner(sib, ['decision-table']) + rc, out = _run(tmp, APPS, sib) + check('control: a used annotation is not stale', 'STALE' not in out, out) + # Declared locally AND annotated: the annotation is dead weight. + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as sib: + _app_with_annotation(tmp, ['task'], [('task', 'openregister')]) + _owner(sib, ['task']) + rc, out = _run(tmp, APPS, sib) + check('annotation for a locally declared slug is STALE', + 'declares it itself' in out and rc == 0, f'rc={rc} {out}') + + +def test_unannotated_foreign_slug_still_fails_as_before(): + """Annotation support must not loosen the plain case, even with a perfectly + readable owner that declares the slug.""" + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as sib: + _tree(tmp, {'app_register.json': {'Task': {'slug': 'task'}}}, + ['task', 'decision-table']) + _owner(sib, ['decision-table']) + rc, out = _run(tmp, APPS, sib) + check('unannotated foreign slug fails', rc == 1, f'rc={rc} {out}') + check('with the original message', + "FAIL tests/e2e/ci-seed.sh: requires schema slug 'decision-table', " + "which no descriptor under lib/Settings declares." in out, out) + # CONTROL: add only the annotation line and it passes. + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as sib: + _app_with_annotation(tmp, ['task', 'decision-table'], + [('decision-table', 'openregister')]) + _owner(sib, ['decision-table']) + rc, out = _run(tmp, APPS, sib) + check('control: same tree plus the annotation passes', rc == 0, + f'rc={rc} {out}') + + +def test_malformed_directive_fails(): + with tempfile.TemporaryDirectory() as tmp, tempfile.TemporaryDirectory() as sib: + _app_with_annotation(tmp, ['task', 'decision-table'], []) + seed = os.path.join(tmp, 'tests', 'e2e', 'ci-seed.sh') + with open(seed, 'a', encoding='utf-8') as fh: + fh.write('# hydra-gate-109 extrnal: decision-table owner=openregister\n') + _owner(sib, ['decision-table']) + rc, out = _run(tmp, APPS, sib) + check('a misspelt directive fails', rc == 1, f'rc={rc} {out}') + check('and says it could not parse it', 'unparseable' in out, out) + + if __name__ == '__main__': for fn in (test_renamed_slug_is_named, test_all_present_passes, test_fragment_key_is_not_a_claim, @@ -196,7 +373,14 @@ def test_seed_without_descriptors_is_wiring_not_pass(): test_case_only_difference_is_not_a_finding, test_descriptor_outside_lib_settings_is_read, test_no_seed_is_na_not_pass, - test_seed_without_descriptors_is_wiring_not_pass): + test_seed_without_descriptors_is_wiring_not_pass, + test_owner_declares_it_passes_and_misspelling_fails, + test_owner_unreadable_fails_naming_what_was_missing, + test_additional_apps_not_provided_fails_not_passes, + test_owner_not_installed_fails, + test_stale_annotation_is_reported_not_failed, + test_unannotated_foreign_slug_still_fails_as_before, + test_malformed_directive_fails): print(fn.__name__) fn() print() diff --git a/hydra-gates/scripts/run-hydra-gates.sh b/hydra-gates/scripts/run-hydra-gates.sh index f2a449332..0cee968da 100755 --- a/hydra-gates/scripts/run-hydra-gates.sh +++ b/hydra-gates/scripts/run-hydra-gates.sh @@ -12339,6 +12339,13 @@ fi # NOT diff-scoped: the defect is a rename landing WITHOUT touching the seed, so # scoping to a diff that contains the seed would skip exactly the change that # causes it. +# +# A seed may require a slug a SIBLING app owns, by saying so on its own line: +# # hydra-gate-109 external: owner= +# The claim is verified, not trusted: the owner must be in the caller's +# additional-apps (HYDRA_GATE_ADDITIONAL_APPS) and its descriptors, checked out +# by quality.yml under HYDRA_GATE_SIBLINGS_DIR/, must declare the slug. An +# owner that cannot be read FAILS and is named; it never passes unchecked. # --------------------------------------------------------------------------- _srs_log=${HYDRA_GATE_LOG_DIR}/hydra-gate-seed-required-slugs.log : > "${_srs_log}" @@ -12363,7 +12370,7 @@ else _srs_n=$(grep -cE '^FAIL ' "${_srs_log}" 2>/dev/null || true) case "${_srs_n}" in ''|*[!0-9]*) _srs_n=1 ;; esac grep -E '^FAIL ' "${_srs_log}" || true - _fail 109 "seed-required-slugs" "${_srs_n} schema slug(s) the e2e seed requires are declared nowhere - the seed would exit before Playwright starts and every spec would report as NOT RUN - see ${_srs_log}" + _fail 109 "seed-required-slugs" "${_srs_n} schema slug(s) the e2e seed requires are declared nowhere, or claimed from an owner that does not declare them or cannot be read - the seed would exit before Playwright starts and every spec would report as NOT RUN - see ${_srs_log}" fi # GATE 108 — shipped-object-properties