Research & Grounding Note: CLI Acceptance Scenarios & Cucumber-JVM Nightly
_Grounded 2026-10-11 against a local working tree, branch cli-acceptance-bdd-nightly-01M4NZRJ. Read-only; nothing modified._
Mission: cli-acceptance-bdd-nightly-01M4NZRJ. Spec: kitty-specs/cli-acceptance-bdd-nightly-01M4NZRJ/spec.md. Governing ADR: docs/adr/4.x/2026-10-11-3-acceptance-testing-and-bdd-support.md (Accepted 2026-10-11).
0. Headline findings (read these first)
- The ADR-as-accepted contradicts the spec on three points the FR-001 amendment must reconcile. The accepted ADR mandates:
tests/scenarios/promise-index.json(public slice of QA's index), a per-PR stdlib checktests/architectural/test_scenario_promise_tags.py(asserts@promiseids areratified, one broken fixture per rule), thescenariosci-router group gating that architectural job, and aPROMISE_INDEX_SYNC_TOKENorg sync (Open Question 1). The owner ruling (2026-10-11) drops all four from core → spec-kitty-qa. None of these artifacts exist in the tree yet (test_scenario_promise_tags.py,promise-index.json— absent), so the amendment is recording a narrowing that was never built, not deleting code. The amendment must restate:scenariosgates no job (was "gates the architectural job");acceptance_jvmstill gates no job (unchanged);@promise/@defect/@qainert in core. - The single trickiest green-keeping edit is
tests/architectural/test_workflow_coherence.py:310—_DELIBERATELY_UNGATED_FILTER_GROUPS = frozenset({"any_src", "ci"}). FR-003b (test_every_restored_filter_group_is_consumed_live) reds for any dorny filter group that gates no job unless it is in this set. The two new groupsscenarios+acceptance_jvmMUST be added here. - FR-003c (
test_every_restored_filter_glob_is_live) forces an ordering/atomicity constraint:tests/scenarios/andacceptance-jvm/globs must each match ≥1 git-tracked path or the router change reds. The seed.featurefiles and theacceptance-jvm/subproject must be committed in (or before) the same change that adds the router groups. contracts/pins Gradle viapins.json+install_tools.py, NOT a committedgradle-wrapper.properties. There is no wrapper jar and no wrapper properties file in the tree. CI installs the pinned, sha256-checked Gradle zip; CI sets up JDK viaactions/setup-java(temurin, currentlyjava-version: '21'— ADR mandates Java 25 foracceptance-jvm).verification-metadata.xmlis Gradle-generated (origin="Generated by Gradle"), hand-edit forbidden.contracts/runs per-PR (contracts.yml,on: pull_requestpath-scoped tocontracts/).**acceptance-jvmmust NOT get an analogous per-PR workflow (NFR-004: 0 per-PR JDK cost) — it runs nightly only.acceptance-jvm/is NOT folded into the existingcorpusgroup (which ownscontracts/and DOES gate per-PR jobs). It is its own non-gating group.- Both
ci-router.yml(54 commits/90d) andci-nightly.yml(40 commits/90d) are hot files — high rebase/merge risk.pyproject.tomlis extremely hot (244/90d) but its sdist section is stable.contracts/is stable (2 commits, all from #5558).
1. Target files — sizes + responsibility map
| Path | Lines | Owner / responsibility | Mission action |
|---|---|---|---|
.github/workflows/ci-nightly.yml | 1443 | Nightly full-mode suites; every current job runs on ubuntu-24.04 (single-OS); nightly-summary aggregates needs: and echoes results (never fails the run). | ADD acceptance-bdd job, 3-OS matrix (FR-010). |
.github/workflows/ci-router.yml | 1164 | Two-authority path router. Authority #1 = dorny changes filter block (group→globs); authority #2 = job if: needs.changes.outputs.<group> (group→job). scripts/ci/gate_selection.py PARSES both. | ADD scenarios + acceptance_jvm filter groups + changes.outputs entries; gate NO job (FR-012, C-005). |
.github/ci-module-registry.yml | 957 | Single data source for module set (module names, roots, test_dirs); consumed by gate_selection._registry_rows. | No change needed (new groups are non-src, no module/shard). |
scripts/ci/gate_selection.py | ~260 | The ONE importable gate-selection authority (#2476). _job_gates reads each job's if: for needs.changes.outputs.<grp>; a group with no job if: reference gates nothing. PROBE_GROUPS={"any_src"}. | No change — it parses, doesn't enumerate. |
tests/cross_cutting/packaging/test_packaging_safety.py | 167 | Wheel/sdist packaging invariants. Uses session fixture build_artifacts (wheel+sdist). Tests marked @pytest.mark.slow + integration. | ADD sdist-exclusion assertion for acceptance-jvm/ + tests/scenarios/, paired with a positive control (FR-013). |
pyproject.toml [tool.hatch.build.targets.sdist] (L207–225) | — | include=["src/*/","packs/built-in/",...]; exclude=[".kittify/",".kittify/active-mission","src/specify_cli//tests/"]. Wheel force-includes packs/built-in only. | ADD "acceptance-jvm/" + "tests/scenarios/" to sdist exclude (FR-013, C-004). |
src/specify_cli/cli/commands/init_help.py | 57 | INIT_COMMAND_DOC — source of truth for the two seed promises. | Read-only source for FR-003/FR-004 scenarios. |
src/specify_cli/cli/commands/init.py | — | The actual init command the doc describes. | Behaviour under test (black-box; steps never import it). |
The two seed promises, verbatim from init_help.py:
- FR-003 (idempotent restore): _"Re-running init in an already-initialized directory is idempotent for project state: it verifies the configured agents' skill surfaces, additively restoring missing per-agent skill roots (e.g. .claude/skills/) through the canonical installer..."_
- FR-004 (no git): _"Creates project files only. Does not initialize a git repository. Does not create any commits."_ + _"init never touches git state regardless of flags."_
2. The contracts/ Gradle precedent — exactly how to mirror it
Files: contracts/build.gradle (2940 B), contracts/settings.gradle (638 B), contracts/gradle/verification-metadata.xml (1032 lines / 63517 B), contracts/tools/pins.json, contracts/tools/install_tools.py, contracts/tools/gradle_pin_check.py, contracts/README.md (25220 B). No gradle/wrapper/ dir, no gradle-wrapper.properties, no gradlew/gradlew.bat, no wrapper jar exist (find contracts -name "*.properties" → empty).
Distribution pinning + checksum (the mechanics to copy)
- Pinned in
contracts/tools/pins.json, entryname: "gradle",kind: "archive",version: "8.14.5",url: https://services.gradle.org/distributions/gradle-8.14.5-bin.zip,sha256: 6f74b601422d6d6fc4e1f9a1ab6522f642c2fdcbc15ae33ebd30ba3d7198e854, pluspublished+advisory_feed_checkedprovenance strings. contracts/tools/install_tools.py(stdlib only) downloads the zip, refuses to unpack or execute until the bytes match the pinned sha256 (codes:CHECKSUM_MISMATCH,CHECKSUM_MISSING,NOT_HTTPS,UNSAFE_ARCHIVE,UNSUPPORTED_INTERPRETER,BINARY_MISSING), then appends the tool'sbin/to$GITHUB_PATH. Invoked in CI aspython contracts/tools/install_tools.py --pins contracts/tools/pins.json --dest "$RUNNER_TEMP/tools" --only gradle.contracts/tools/gradle_pin_check.py(stdlib + PyYAML) cross-checks that the versions the build USES equal the pins: theid 'org.openapi.generator' version '...'line ofbuild.gradle, the plugin components ofverification-metadata.xml, agradle-wrapper.propertiesonly if it exists (_check_wrapperearly-returns when absent — so the no-wrapper pattern is a first-class supported state), and everygradle-version:input ofcontracts*.ymlworkflow steps. One failure code:GRADLE_PIN_MISMATCH.
No committed wrapper jar
settings.gradle comment is explicit: _"Invoked ... with a pinned Gradle distribution; there is no committed wrapper jar (see tools/install_tools.py and tools/pins.json)."_ CI invokes the installed gradle binary directly (on $PATH), not ./gradlew.
verification-metadata.xml
- Stored at
contracts/gradle/verification-metadata.xml, 1032 lines. Schemadependency-verification-1.3.xsd;<configuration><verify-metadata>true</verify-metadata><verify-signatures>false</verify-signatures></configuration>; one<component>/<artifact>per dependency with<sha256 ... origin="Generated by Gradle"/>. - Generated, never hand-edited (build.gradle comment: _"generated, never hand-edited"_). The canonical generation command is documented in the supply-chain toolguide (see §4):
gradle --write-verification-metadata sha256 <tasks> --no-daemon(plus--write-locksonly whendependencyLockingis enabled). README line 243 referencescontracts/gradle/verification-metadata.xmland thegradle-versionpin together.
JDK
- ADR + spec require Java 25 for
acceptance-jvm(per the 2026-10-03 amendment of ADR 2026-10-01-2). Note divergence:contracts.ymlcurrently pinsactions/[email protected],distribution: temurin,java-version: '21'. Soacceptance-jvmcannot copy the JDK version verbatim — it bumps to 25.
Is contracts/ run in CI today?
Yes — per-PR, via .github/workflows/contracts.yml (on: pull_request + push: main, path-scoped to contracts/, tests/contract/, the two contract workflows, uv.lock, pyproject.toml, pytest.ini, tests/conftest.py, src/specify_cli/status/lifecycle_events.py). It validates/bundles/cross-checks contracts; gradle_pin_check.py runs at L72; Gradle installed at L130; JDK set up at L124/L327. This is the anti-pattern for acceptance-jvm: the new runner must be nightly-only (NFR-004), so do NOT create an acceptance-jvm.yml per-PR workflow.
3. Invariants that must survive (with citations)
Packaging (sdist/wheel)
``python with tarfile.open(sdist_path, "r:gz") as tar: all_files = tar.getnames() bad_kittify_files = [f for f in all_files if ".kittify/" in f and "/src/" not in f] assert not bad_kittify_files, ... ``
- Wheel ships only
src/packages +packs/built-in— ADR §Packaging ("The wheel ships only thesrc/packages and is unaffected"); CLAUDE.md "Pack Tiers"; enforced bytest_packaging_safety.py::test_wheel_contains_only_known_packages(known_prefixes = specify_cli/, doctrine/, charter/, kernel/, glossary/, mission_runtime/, runtime/, packs/built-in/) andtest_wheel_config_only_includes_built_in_pack_skills(config-only twin). The runner/scenarios never touch the wheel — they are top-level dirs outsidesrc/, so wheel tests already exclude them; no wheel assertion change needed. - sdist exclusion is the FR-013 deliverable. Current sdist
exclude(pyproject L221–225) does NOT listacceptance-jvm/tests/scenarios. Must add both. The existing packaging test has no generic sdist-exclude assertion — onlytest_sdist_contains_no_kittify_paths(L~155). FR-013 adds a NEW@pytest.mark.slowtest asserting both paths are absent from the built sdist AND a positive control (e.g. asrc/package IS present — mirror thetest_wheel_contains_templatespositive-control style). - Exact assertion shape to mirror (quote,
test_sdist_contains_no_kittify_paths): - Positive-control style to mirror (
test_wheel_contains_templates):assert template_files, "Wheel missing template files".
ci-router group→gate model (the "gates no job" mechanic)
- Two authorities, one parser. ci-router.yml header (L1–30): authority #1 = dorny filter block (group→globs); authority #2 = job
if: needs.changes.outputs.<group>(group→job).gate_selection.load_routerparses both;_job_gates(L108–115) maps each job→frozensetof the groups itsif:references. - "Gates no per-PR job" = no job's
if:references the group.select_gates(L134–163):gated_selected = {job for job,groups in router.job_gates if groups & selected_groups}. A group that appears in no jobif:contributes to nothing. This is exactly howciandany_srcwork today. Reference comment in ci-router.yml for thecigroup (L~262): _"No job gates on this group BY DESIGN."_ - Workflow (never push main): CLAUDE.md "Git Workflow" —
mainis integration-only; open a topic branch + PR intomain;spec-kitty consolidateis local-only. Branch is alreadycli-acceptance-bdd-nightly-01M4NZRJ.
Supply-chain pinning (NFR-003)
- 100% JVM deps in generated
verification-metadata.xml; Gradle distribution checksum-pinned; 0 wrapper jars committed. Same model as §2.
4. Architectural / AST gates that reference or would be tripped by these files
Ranked by likelihood of tripping:
1. tests/architectural/test_workflow_coherence.py — FR-003b (HIGH, WILL TRIP).
_DELIBERATELY_UNGATED_FILTER_GROUPS = frozenset({"any_src", "ci"})(L310).unconsumed_filter_groups(L330) =(filter_groups - this_set) - consumed_by_any_job_if.test_every_restored_filter_group_is_consumed_live(L128) asserts it is empty.- To keep green: add
"scenarios"and"acceptance_jvm"to this frozenset. (Honest: they genuinely gate no job, so the exemption is truthful, not green-washing.) The fault-injection twintest_faultinjection_deliberately_ungated_group_is_exempt_but_orphans_still_red(L426) proves the set still catches real orphans.
2. tests/architectural/test_workflow_coherence.py — FR-003c (HIGH, ORDERING CONSTRAINT).
test_every_restored_filter_glob_is_live(L157) +glob_is_live(L338): every filter glob must match ≥1 git-tracked path (_tracked_paths=git ls-files, L109).- To keep green:
tests/scenarios/andacceptance-jvm/must be tracked when the router groups land. Commit seed.features + the subproject with/before the router edit.VESTIGIAL_FILTER_GLOBSledger (L56) is empty{}and is for DEAD globs only — do NOT add rows for live globs.
3. tests/architectural/test_ci_router_transcription_guards.py (MEDIUM).
test_router_src_filters_derive_from_scrub_verbatim(L104): requires scrub groups ⊆ router filters, and that src-backed router groups == src-backed scrub groups. New groups are non-src, and the scrub (tests/release/ci_retirement_scrub.json, groups list confirmed:consolidation, missions, post_merge, release, status, review, next, lanes, upgrade, cli, charter, agent, kernel, glossary, execution_context, core_misc, unit, specify_cli_runtime, ci, auth) requires NO entry for them (precedent:mission_state,corpus,ci_config,contract_tools,docs,e2e,architecturalare all non-src router groups absent from scrub and pass). No scrub edit needed.test_changes_outputs_cover_every_routing_group(L148, WILL TRIP if forgotten):changes.outputs(minusunmatched) must EQUAL the filter routing groups. Must addscenarios+acceptance_jvmto thechanges.outputsblock (mirror the existingfoo: ${{ (inputs.mode == 'full' || ...unmatched...) && 'true' || steps.filter.outputs.foo }}line shape).test_unmatched_union_covers_every_src_backed_group(L172): new groups are non-src → must NOT be added to theunmatchedunion loop. (Matchdocs/corpus/citreatment.)
4. tests/architectural/test_gate_selection_authority.py (LOW). Parses real on-disk router; proves docs/corpus are non-src & excluded from unmatched loop. New non-src groups follow the same path; non-vacuity floor unaffected.
5. tests/architectural/test_ci_corpus_trigger_completeness.py (LOW). Corpus-specific (Gate-0 on.paths completeness for @corpus tests). Not triggered by scenarios unless you add @pytest.mark.corpus readers — don't.
6. tests/architectural/test_ci_quality_path_filters.py (LOW). Parses the router filter block shape; two-authority model guard. Adding well-formed groups is within its model.
7. tests/charter_offering/test_supply_chain_single_owner.py::test_covers_maven_strict_checksums_and_gradle_write_verification (L220, NOT TRIPPED — reference only). Asserts the Java toolguide MD contains --write-verification-metadata, verification-metadata.xml, --write-locks, dependencyLocking/lockAllConfigurations. This is doctrine-prose coverage, not a gate on acceptance-jvm/. It is the canonical source for the exact Gradle verification-metadata command to put in the acceptance-jvm README (gradle --write-verification-metadata sha256 ... --no-daemon).
8. tests/release/test_release_ci_ownership.py / tests/ci/test_contracts_workflows.py (CHECK). Pin per-workflow job sets and "every test has exactly one per-PR home." Adding a nightly-only job to ci-nightly.yml should be in scope of ci-nightly, but verify these don't enumerate a frozen nightly job set that a new acceptance-bdd job would violate. (Not read in depth — flag for implementer.)
5. Test seams and what they cost
- How packaging tests build artifacts: session fixture
build_artifacts(tests/conftest.py:1242) →tests/_support/shared_build_artifacts.py::default_wheel_sdist_builderrunspython -m build --no-isolation(NOTuv build;build+hatchlingare pinned test extras). ~1 minute per run, built once per pytest run and shared across xdist workers via akernel.locksmachine lock (_LOCK_TIMEOUT_S = 1200). All packaging assertions are@pytest.mark.slow+@pytest.mark.integration→ they run in the slow/nightly/full tier, notmake test-fast. - Note vs spec: the packaging TEST uses
python -m build; the spec/ADR'suv buildrequirement (C-002) is for the CLI-under-test wheel built by the nightly JVM runner, a separate context. No conflict. - Existing JVM/Gradle test invocation: only
contracts.yml(per-PR, path-scoped). There is NO pytest that drives Gradle, and NO JVM test intests/.acceptance-jvmintroduces the first nightly JVM run; its own suite runs inside Gradle/JUnit Platform, not pytest.gradle_pin_check.pyis the only Python that reasons about a Gradle build and it has unit tests undertests/contract/(contract-tools lane).
6. Open-P0 / sibling-run interactions & 90-day churn
| File | Commits/90d | Churn verdict |
|---|---|---|
.github/workflows/ci-router.yml | 54 | HOT — high merge risk. Recent adds are the SAME pattern we repeat: mission_state group (#5864, aaccc6b6dc), contract-cited src routing (#5838/#5776). Rebase likely; keep the router diff minimal and additive. |
.github/workflows/ci-nightly.yml | 40 | HOT — high merge risk. Recent: p0-repro lane split (fcfc7559f0), interpreter-shard gaps (f3e4b78856), test-universe collection (14bd5dbb35). Append the acceptance-bdd job at the end; decide whether to wire into nightly-summary.needs (see below). |
pyproject.toml | 244 | Extremely hot overall, but the [tool.hatch.build.targets.sdist] exclude block is stable. Expect conflicts on unrelated hunks; your 2-line add is low-risk if isolated. |
tests/cross_cutting/packaging/test_packaging_safety.py | ~8 | Moderate; last touched by WP05 pack-skill wheel-safety (7fa2395688) + landing retirement (f2b25d23b2). |
contracts/build.gradle, contracts/tools/pins.json | 2 | Stable — both from #5558 (Sept 2026). Safe to copy from. |
- Sibling-run interaction —
nightly-summarydecision point:ci-nightly.yml:1422nightly-summary.needsis a fixed 16-job list and the job onlyechos each result (always(), never fails). The new non-gatingacceptance-bddjob can be left OUT ofneeds(it uploads its own artifacts; nothing downstream in core consumes it — US-3 says QA consumes cross-repo). If added for visibility, extend both theneeds:list and the echo block. Recommend: leave out ofnightly-summaryto minimise the hot-file diff, unless a release-readiness gate requires it (verifyrelease-readiness.yml/tests/release/test_release_ci_ownership.py). - No open P0 reproductions reference these files in recent history (the
p0-reprolane commits are about lane plumbing, not these paths). The churn is feature/landing work, not firefighting. - Fork context: this is a shadow clone; several "90-day" commits are convergence imports (
ec62c35de3 chore(charter): ... CI workflows). The hot-file signal still holds for merge planning.
7. Decision-relevant contradictions / complications for the spec
1. FR-001 amendment must flip "scenarios gates the architectural job" → "gates no job" and record the deletion-before-birth of promise-index.json, test_scenario_promise_tags.py, and PROMISE_INDEX_SYNC_TOKEN (ADR Open Question 1). These never existed in core, so FR-014's "drop" is really "never add." 2. Atomicity: FR-012 (router groups) cannot land before FR-002/FR-005 (tracked tests/scenarios/ + acceptance-jvm/ files) or FR-003c reds. Sequence the WPs so the directories are tracked first, or land them together. 3. test_workflow_coherence.py:310 frozenset edit is mandatory and is a test-file edit in tests/architectural/ — the mission's implement phase owns it; grounding did not touch it. 4. JDK bump: acceptance-jvm uses Java 25; the contracts/ precedent uses Java 21 — do not copy the java-version verbatim. 5. Gradle version mirror: spec/assumption says "pinned Gradle mirrors contracts/" → 8.14.5, sha256 6f74b601...e854. A separate acceptance-jvm/tools/pins.json (or reuse) + its own verification-metadata.xml is needed; gradle_pin_check.py currently only globs contracts*.yml workflows and contracts/ paths, so it will NOT automatically cover acceptance-jvm — either generalise it or accept that acceptance-jvm pin-drift is unguarded (flag for the implementer; NFR-003 wants it guarded). 6. verification-metadata.xml generation command (copy into the acceptance-jvm README): gradle --write-verification-metadata sha256 <tasks> --no-daemon, run once, commit the result, never hand-edit (origin="Generated by Gradle").
8. Plan-phase decisions (Phase 0 resolution)
- DM-01M4P2B3S88TYZAK2WH5HM12PZ — Gradle pin-drift guard scope.
- Decision: Generalize the shared
contracts/tools/gradle_pin_check.pyto also scanacceptance-jvm/and theacceptance-bddnightly job'sgradle-version, keeping one canonicalGRADLE_PIN_MISMATCHauthority (IC-04). - Rationale: single canonical authority (charter Quality Standing Orders); fully satisfies NFR-003; the guard already tolerates an absent wrapper file, so the no-wrapper acceptance-jvm shape is first-class.
- Alternatives considered: a separate acceptance-jvm pin-check (rejected: duplicated logic, two authorities); accept unguarded drift in v1 (rejected by operator: NFR-003 wants it guarded).
- DM-01M4NZV51YAVWX0WHNCZGTB0CS — seed scenario tag posture. Seed
initscenarios land bound + untagged (gating run);@manualreserved for heavy/on-demand scenarios. (Resolved in specify; drives FR-003/FR-004 and the Tag entity.) nightly-summary.needswiring (no DM — in-scope engineering call): leave the non-gatingacceptance-bddjob OUT of the fixed 16-job summary list to minimise the hot-file diff; it uploads its own artifacts and nothing in core consumes it. IC-06 verifiesrelease-readiness.yml/tests/release/test_release_ci_ownership.pydo not require it; if one does, wire it in.- Installer reuse (no DM — single-canonical-source call): reuse
contracts/tools/install_tools.pyfor acceptance-jvm's Gradle install (ownpins.json), rather than copying the installer.
Supply-chain adversarial-evidence disposition (plan readiness)
Per the adversarial-squad-deployment findings-disposition contract: the dependency decisions (Cucumber-JVM 7, allure-cucumber7-jvm, JUnit Platform, pinned Gradle 8.14.5) replicate the already -reviewed contracts/ supply-chain model (sha256-pinned distribution via a checksum-refusing installer; Gradle-generated per-artifact verification-metadata.xml; no committed wrapper jar; pin-drift guarded). Novelty is low and no new PyPI advisory surface is introduced. The adversarial challenge pass is explicitly deferred to the closeout pre-PR review squad (semi-auto delivery step 8), not run as a standalone pass now. No contested finding is dropped; any raised at closeout will be dispositioned here before the PR flips to ready-for-review.