tephpy dependency floors — design specification#

Living document. This specification is maintained alongside the code, not archived behind it. .github/workflows/ci-floors.yml and .github/scripts/floors.py cite it by section — floors spec §3.2 and the like — so these sections are the reasoning behind what the job does, and where the two ever diverge it is the specification that gets corrected. Read it as current.

  • Date: 2026-08-13 (originated; maintained since)

  • Status: living design specification

  • Citation prefix: floors spec §… — not deps spec, which would read as covering the whole dependency policy rather than its lower bound alone

  • Scope: a scheduled job that resolves tephpy’s declared dependency minimums, exercises what it resolves, and reports what it finds; the declarations themselves stay where they are

  • Parent spec: 2026-07-22-tephpy-design.md — adds a scheduled workflow to spec §8.7 and inherits its pixi (spec §8.2) and SPEC 0 (spec §8.3) rules unchanged

  • Sibling spec: 2026-08-07-config-file-design.mdconfigfile spec §7 declares four floors and records the gap this closes, including which of them were resolved by hand and when

1. Purpose#

tephpy declares a minimum version for every dependency it takes, and nothing it runs resolves one. Every pixi task passes --frozen (spec §8.2), so what CI installs is the lockfile’s pin: when configfile spec §7 was written the lockfile carried click 8.4.2 against a declared floor of >=8.1, and the whole span between them was untested. A floor set too low therefore does not fail here. It fails for one person — the user whose solve happens to land on that version — in an environment this repository never sees, and the first report of it arrives as a bug that looks like tephpy’s.

The lockfile-update bot in spec §8.7’s fast-follow list does not close this. pixi update moves the resolved point upward, so that job tests the ceiling. The floor is the other end of the same declaration and needs its own job; both are worth having and neither substitutes for the other.

The floors have been resolved by hand twice, each time against a fixed list: three of them as they were declared (#112), the fourth afterwards (#109, #133). That second exercise is the argument for automating the first. Establishing one floor took a pair of scratch environments differing in a single package and a whole-tree diff of two documentation builds, which is not a thing anyone will repeat on every dependency change.

A third exercise, run while this specification was being written, resolved the core and test tiers at their floors on Python 3.12 and found two failures already sitting there (2026-08-13). matplotlib-base >=3.10 does not reach a test: 3.10.0 calls pyparsing.oneOf, deprecated in the pyparsing that solve chose, and tephpy’s filterwarnings = ["error"] turns the warning into an error while conftest.py is still importing. pint >=0.24 fails to import on 3.12 at all, raising TypeError: cannot inherit frozen dataclass from a non-frozen one, reached through metpy. Both floors are declared, both are broken, and nothing this repository runs today can see either.

2. Decisions#

  • Pins are generated from the manifest, never declared a second time. A hand-written floors environment is a second copy of every floor, and a floor raised in one copy and not the other leaves the job testing a version nobody declares. Generated by resolving each floor to a release the channel carries, too, rather than by re-typing the floor: most of tephpy’s floors are not themselves releases (§3.2).

  • Python is pinned to the SPEC 0 lower bound. The floors are one axis of a corner; the supported Python is the other, and only both together are the corner (§3.3).

  • Scheduled weekly, never a pull-request gate. The job goes red for reasons no pull request caused — a build withdrawn upstream, a transitive that shifted — and a required check that fails for something the author did not do is a check people learn to ignore.

  • The job diagnoses; a person opens the pull request. It carries the diagnosis as far as naming a version and the lines to change, and stops there (§3.6). A bot pull request would need a changelog fragment named for a number that does not exist until the pull request is opened, and raising a floor is a user-visible change that wants a judgement the job cannot make.

  • The scan upward is linear, not a bisection. A bisection assumes pass and fail are monotonic in version, which nothing has established. The job is scheduled and nothing waits on it, so it buys the weaker assumption for runtime nobody is spending (§3.5).

  • One issue per tier and package. Each is a separate fix, and the identity is what makes a weekly job that keeps finding the same broken floor comment rather than file again (§3.6).

3. Architecture#

3.1 The two declaration sites#

Every floor is declared twice, once for pixi and once for pip:

tier

pixi

pip

core

[tool.pixi.dependencies]

requirements/pypi-core.txt

test

[tool.pixi.feature.test.dependencies]

requirements/pypi-optional-test.txt

docs

[tool.pixi.feature.docs.dependencies]

requirements/pypi-optional-docs.txt

devs

[tool.pixi.feature.devs.dependencies]

requirements/pypi-optional-devs.txt

The two sides are not a mirror of each other, and a job that assumed they were would test a set neither of them declares. Four divergences stand. Two are one package under two names: conda takes matplotlib-base, conda-forge’s matplotlib without the GUI toolkits, where pip takes matplotlib; and python-build where pip takes build, conda-forge naming a package around one the channel already uses. One is a package the two sites floor in different tiers: setuptools and setuptools-scm, the build backend, are a core declaration for pixi and a test requirement for pip (#153). The fourth has no counterpart at all — conda’s docs tier carries make, which drives the documentation build and has nothing on PyPI worth declaring.

So each site is read on its own terms, and neither list is reconciled against the other. Nor is either enumerated here: the manifest is the enumeration, and docs spec §4 rules out a copy that has to be re-measured to stay true. The one place the two are held together is the issue of §3.6, which is one issue per floor whichever half found it, and so has to know that each of those pairs is a single package.

The pixi column is two tables, not one. Beside each dependencies table above sits a pypi-dependencies table, which pixi installs from PyPI rather than from conda-forge, and a floor in either is a declaration of the same kind: resolved, pinned, and paired with the one requirements file its row names. Which of the two a package is declared in is not a question of where it can be found — conda-forge carries playwright 1.62.1, and the docs tier still declares playwright >=1.55 from PyPI (measured 2026-08-15). The table is therefore read as the statement of which index that package is installed from, and its floor is resolved against that index and no other (#151). A name declared in both tables of one tier fails the run rather than being resolved by the order the generator happens to read them in — judged on the names the two tables declare, not on what those declarations resolved to, since a source entry resolves to nothing and would otherwise carry a second provider past the guard.

One entry shape in a pypi-dependencies table is not a floor at all: a source, such as the local editable path the manifest gives tephpy itself. There is no version there to resolve, and writing a pin over it would install a release of tephpy over the checkout under test — so it is passed over, and named in the run summary. A job that exercises fewer declarations than the manifest makes should not read as one that exercised all of them.

Both sites move by hand only. dependabot was pointed at requirements/ and opened its first run against it while this job was being planned (#131), lifting every >= there to the newest release — click >=8.1 to >=8.4.2, xarray >=2024.10 to >=2026.7.0. That is the automatic floor raise §2 rejects, made without evidence that any of those floors was broken, and on the site that [tool.setuptools.dynamic] publishes as tephpy’s install requirements. It would also have left this job with nothing to find: --resolution lowest-direct reads those same lines (§3.3), so floors lifted to the newest release make the PyPI half a test of the ceiling, which §6 rules out. The ecosystem is therefore parked at open-pull-requests-limit: 0 (spec §8.7), which stops version updates and leaves security updates running — an advisory being the one case where a floor should rise on evidence, with a person reading it.

3.2 The pin generator#

.github/scripts/floors.py, alongside the gate scripts already there. For each dependency table named in §3.1 it reads the declared >=X, asks that table’s index which versions satisfy it, and rewrites the specifier to == the lowest of them in the CI checkout; the job then solves without --frozen. This is the throwaway edit #133 made by hand, generated rather than typed.

Pinning the floor string would not do, and the manifest as it stands is the proof. A conda specifier matches a version that exists, and most declared floors are not releases: >=8.1 names click 8.1.0, >=2024.10 names xarray 2024.10.0. Trailing-zero equivalence rescues those two by accident — ==8.1 does resolve to 8.1.0 — but not setuptools-scm >=8, whose lowest 8.x release on conda-forge is 8.0.1, so ==8 matches nothing and the run fails on a pin the generator wrote rather than on a floor tephpy declared. Resolving the floor to a release first is what makes the pin mean what the declaration means. (Measured 2026-08-13.)

The query that resolves a floor also enumerates everything above it, which is the scan’s candidate list (§3.5); the two ask that index once for the same thing. Ordering those candidates is PEP 440 rather than string comparison, under which 10.0.5 sorts below 8. That takes packaging, which is declared rather than inherited as a transitive of matplotlib — the same reasoning configfile spec §7 records for platformdirs. It is declared in the test tier, because the test suite is what imports it and §3.3’s generated core + test carries no devs; a declaration there would be one the tier that needs it cannot see.

Candidates are then filtered by the python constraint each build declares, which is not an optimisation. click 8.1.0 exists on conda-forge and its builds stop at Python 3.10, so a pin written from the unfiltered list is unsolvable on the 3.12 of §3.3, and the run fails on the generator’s arithmetic rather than on anything tephpy declared. Filtered, >=8.1 resolves to click 8.1.3. (Measured 2026-08-13.)

The PyPI side of §3.1 is filtered the same way, on the requires-python each release publishes, and yanked files are passed over there. A yank is the index saying the release should not be installed, and --resolution lowest-direct on the other half of the job honours it — so a generator that did not would pin the two halves to different releases, and report a floor neither of them would install.

requires-python is not on its own a statement of what a release runs on, and is often absent. A release counts only if it carries a file the target could install: an sdist, or a wheel whose tags the pinned Python and the runner’s platform accept. pywin32 311 publishes fifteen non-yanked wheels and no sdist, every one of them for Windows and none declaring a Python at all — read on that metadata alone it is a floor the Linux runner would pin and then fail to install, and one the §3.5 scan would climb through releases it can never reach (measured 2026-08-15).

Four guards, each of which fails the run rather than degrading it:

  • A specifier that is not a bare >=X is reported, never skipped. A range, a wildcard, a build string: the generator cannot know which version such a specifier floors, and one that quietly converts most of a tier makes the run a weaker claim than it appears to be.

  • A tier whose conda table yields no conversions fails. A table emptied or renamed would otherwise exit 0 having pinned nothing, and a green run that checked nothing is indistinguishable from a green run that checked everything. Its pypi-dependencies table is not held to this, most tiers declaring nothing there.

  • An entry that is neither a floor nor a source is reported. pixi takes a table of options in a pypi-dependencies entry — extras, an index, an environment marker. Passing one over would leave a declared floor unexercised, and pinning the version key out of it would drop the rest of the table on the way.

  • A package declared in both of a tier’s two tables fails (§3.1), rather than taking the pin the line-based rewrite happens to reach first and leaving the other declaration floating.

One consequence of resolving rather than transcribing is left unclassified: the generated pin routinely sits above the declared floor. Sometimes the floor is not a release (>=8.1 names click 8.1.0); sometimes the lowest releases carry no build for the pinned Python (>=8.1 resolves to click 8.1.3, above); and sometimes the channel holds nothing in the declared span at all — conda-forge carried nothing below setuptools 78.1.0 against a declared >=77.0.3 when this was written, so that span cannot be tested by this job or by anyone. Only the third is worth a reader’s attention, and no rule separates it from the other two: a threshold that flagged setuptools would flag click 8.1.3 just as loudly. So the job judges none of them.

What it does instead is write every declared floor beside the version it pinned and the table it read that floor from, to the workflow’s step summary, together with every entry it left alone (§3.1). That is what makes the third case legible without the job having to name it, and a silently narrowed run noticeable without reading logs.

The rewrite lands in the checkout the test tier’s exercise then runs the suite in (§3.3), so the manifest that suite reads is not the one this repository declares. A test asserting a literal specifier from the working tree therefore fails that tier on every run — python-build = "==1.5.0" against a declared >=1.5, reported as a floor that would not hold (#155) — and no floor is at fault. It is not only the specifiers: generation also replaces the environment table and drops every feature the surviving environment cannot reach (§3.3), so the tests of those two rules met the same end the week after they were written. Any test reading pyproject.toml for what this repository declares reads it from the index, as the tests that enumerate the committed corpus already do (docs spec §3.6): the declaration is a property of the repository, not of the tree this job leaves behind.

3.3 What runs at the floors#

All four tiers are resolved at their floors. Only where exercising means something is anything run:

tier

half

exercise

test

conda

the test suite, without image comparison

docs

conda

pixi run docs — the build, plus the gates of docs spec §3.6 and docs spec §3.7

devs

conda

installed only

test

PyPI

the test suite, under the interpreter the install went into

docs, devs

PyPI

installed only

A tier is its own floors and core’s, on both halves. The names are shared deliberately: the issue of §3.6 is keyed on tier and package, so a half calling the same tier something else — core-test against test, as this workflow first did — files a second issue for what is one broken floor and one fix.

The pixi environments the conda rows run in are generated with the pins (§3.2), because none of the declared ones is this shape: the committed environments pair each tier with devs and resolve against the newest supported Python. Each tier is generated into a manifest of its own, holding that one environment and no other, and the tiers run as separate jobs. This is not tidiness. pixi solves every environment a manifest declares, whichever one is asked for, so a single manifest carrying all three would let an unsatisfiable docs floor stop the test environment from ever being installed — and the tier that failed would not be the tier that reported it. Separate manifests make each tier’s verdict its own, which is what §3.4 then attributes and §3.6 files.

Holding one environment leaves every other feature defined and used by nothing, and pixi warns once per orphan. That block runs ahead of the solver output the diagnosis quotes verbatim into the issue it files, where it is the first thing a reader sees and none of it is about tephpy (#150). So the generator drops those features outright — every table of a dropped feature, not only the dependency table it knows by name — rather than suppressing the warning, which the generated manifest wants kept for its own real defects.

devs is installed and not run. Its packages are linters, and pre-commit at a floor ruff reports that version’s rule set rather than anything about tephpy’s floors — a red run that is not a finding is worse than no run at all. The image comparisons come out of the test run for the same reason: pytest-mpl compares against baselines generated under the matplotlib the lockfile pins, so at a floor matplotlib they report the version difference, every time, whatever the state of the floor.

Keeping devs out of the test tier costs one declaration. test_masters_ship_in_the_wheel builds a wheel with python -m build, and build is declared only under devs — every committed environment pairs test with devs, so the test suite’s need for it has never had to be stated. At a generated core + test it fails outright, on every run, and §3.4 would then go looking for a package floor to blame and find none. A package the test suite runs is a test dependency wherever it happens to be declared today, so the test feature declares it, exactly as nbformat is declared there for the matrix environments that carry no docs feature (#95).

The PyPI half needs no generated pins. --resolution lowest-direct reads the floors straight from the requirements files and installs the lowest version each one allows, which is exactly the claim the pip declaration makes: that pip install tephpy works at what it says. What it installs into is a virtual environment of its own, and the exercise runs that interpreter: the one the job started with carries whatever resolved for the job, not the floors under test, and a suite green there is green about the wrong environment. Its docs and devs tiers are installed and not run for the reason devs is on the conda side, plus one of their own — the documentation build needs make, which the pip declaration deliberately does not carry (§3.1). So a failure on either is a failure to install, and attribution (§3.4) is the whole of its diagnosis.

The two halves are not the same test and are not expected to agree. The conda side writes hard == pins, so a pair of floors that cannot hold together fails; lowest-direct will lift a direct requirement above its floor to make the set resolve, and report success. The docs tier showed both readings at once when this was written: sphinx-design >=0.6 alongside sphinx >=8.0 has no conda solution, because sphinx-design 0.6.0 requires sphinx <8, while the same two requirements resolve from PyPI by taking sphinx-design 0.6.1 (2026-08-13). Each verdict is right about its own claim, which is why §3.6 carries both rather than reconciling them.

Python is 3.12 throughout, the lower bound of the SPEC 0 window (spec §8.3). Floors resolved against the newest supported Python would not be the lower-left corner of what tephpy supports, and the corner is the point.

3.4 Attribution#

A failure has to be attributed to one package before anything can be done with it. Where the floors resolve and the exercise fails, the traceback usually names it. Where the solve fails, it often does not: a conflict can name several packages, or none of the ones actually at fault.

So the job attributes by relaxation. For each floor in the failing tier, it re-solves with that one specifier returned to the declaration’s own >=X, leaving the rest pinned. The floor whose relaxation lets the tier solve is the culprit. These are solve-only probes and cost minutes.

A probe is the failing leg run again, so what the leg had it has: the tree is copied whole, index included. Twenty of the test tier’s tests guard on a repository being there — the one that builds a wheel from git archive HEAD among them — and a probe without one runs a thinner suite than the leg it is diagnosing, then reports the failure as a step it does not reproduce when the failing step is a test it skipped (#154). What the leg left behind is dropped, though: byte-code and built pages from the run under diagnosis make the exercise report that run’s state rather than its own.

What a probe costs is its environment, not its copy of the tree, so the job holds one at a time: a probe is dropped as soon as it has answered, here and in the scan of §3.5. The relaxation that solves is the exception, being the one whose resolve is read for the bound below.

If relaxing each in turn resolves nothing, the job reports the solver’s output verbatim and stops. An unattributed failure is a real result and is reported as one; a guess dressed as an attribution would send whoever reads the issue after the wrong package. That branch is not only for conflicts too tangled to isolate. Relaxation reaches declared floors and nothing else, so a tier broken by a transitive the solve chose — one no table names, and so one no probe can return — has nothing to relax and lands here by construction.

What relaxation means differs between the two sites, though what it establishes does not: exactly one package is not at its floor. Which floors are walked differs too — each half reads them from the site it installs from (§3.1), so a PyPI diagnosis walking the manifest would relax matplotlib-base, a name the package index has never heard of, and attribute nothing. On the conda side the generated pin is returned to the declaration’s own >=X and the solver takes what it likes. On the PyPI side there is nothing to return, because --resolution lowest-direct puts every direct requirement at its floor without a pin being written (§3.3) — and it is a flag over the whole resolution, with no per-package escape. So relaxation there rewrites that one requirement to pin the version the default resolution would have chosen. Dropping its lower bound instead would not relax it at all: unconstrained under lowest-direct it resolves lower, to the oldest release the index carries. A package that default resolution does not install — an extra some Python or some platform excludes — has no version to be relaxed to, and is reported as a probe that did not solve, which is what it is: nothing was tried, so nothing is attributed to it, where a guessed pin would report an attribution against a resolve the resolver never made.

3.5 The upward scan#

Given a culprit, the job establishes what would work. It takes the candidates §3.2 already enumerated, keeps those at or below the version the relaxed resolve of §3.4 chose and installable on the pinned Python, and tries them in ascending order — stopping at the first that both resolves and passes that tier’s exercise from §3.3. The candidates are read from wherever that package is installed from, which is the table that declares it (§3.1): the conda channel for a dependencies table, the package index for a pypi-dependencies one and for the PyPI half throughout. A package can be at a different version in each, and the two halves are scanned separately for that reason — as is a package the pixi half itself takes from PyPI, which conda-forge may carry at a version the tier will never install.

Ascending and linear, so the first pass is by construction the lowest version that works, with no assumption about the shape of the pass/fail boundary between the floor and it.

Where nothing passes, the scan keeps what its highest candidate failed on. The failure it already reports is the failure of the floors as declared, which is why the tier is red and so the one thing its reader knows before opening the issue; what stopped the candidates is the new information, and it is usually a second floor that relaxing the first left broken. Attribution and the scan ask different questions — a relaxation only has to resolve (§3.4), a candidate has to resolve and pass the exercise (§3.3) — and everything in that gap reaches the reader through this trace or not at all. It reached them not at all in #145: docs reported no passing sphinx-design of three tried, of a package whose 0.6.1 is sound, and the sphinx-autoapi that was actually stopping every candidate took a manual resolve-and-build cycle to find (#148, #149).

What the scan yields is the lowest version that passes what tephpy runs, which is a weaker claim than the lowest version that is correct, and the issue says so (§3.6). The case is in this repository’s own history: sphinx-click 6.0.0 resolved and built the documentation clean under --fail-on-warning with both output gates green, and still rendered a page differently from the version the lockfile pinned (#109, configfile spec §7). A green probe would have reported that floor as sound, which it is — but for reasons a probe did not check.

3.6 The issue contract#

The job files an issue per tier and package, labelled type: dependencies and a marker label that makes the set findable. The body carries what a fix needs:

  • the tier, the package, and the floor as declared

  • the failure, trimmed to the part that identifies it, and a link to the run

  • the lowest version that passed the scan, or a statement that no attribution was reached — and, where both halves failed, the result of each, since a package can be at a different version in the conda channel and the package index (§3.5)

  • both lines that declare the floor (§3.1) — a fix that changes one and not the other leaves the two sides disagreeing — each named as its own site spells it and in the tier that site floors it in, neither of which the two are guaranteed to share. Both are asked of the site itself, on whichever half produced the finding, because neither can be read off the other and the reader is sent to both. Where a floor has no counterpart at all, make being declared for pixi alone, the issue says so and names the one line rather than a second file with nothing in it to edit

  • the caveat of §3.5 in as many words, with the sphinx-click case cited, so the version is read as a starting point rather than an answer

  • where no candidate passed, the highest one tried and what it failed on (§3.5), named as that version’s failure and quoted beside the declared floors’ own; and with it the solve-against-exercise asymmetry, without which attributed a package, and no version of it passes reads as a statement that the package has no good version

Deduplication keys on tier and package against the open marker-labelled issues: an existing one is commented on, not filed again. A floor that stays broken would otherwise raise an issue every week. Two broken floors raise two issues, because they are two fixes — and one floor failing in both halves raises one, because it is one fix. The key deliberately omits the half for that reason: the two declaration sites are edited together (§3.1), so splitting them across two issues would invite a fix that closes one and leaves the pair disagreeing.

Which means the key needs one spelling of a package that has two (§3.1). It takes the manifest’s, for no reason beyond its being the half that has been filing, and the other rides along beside it — matplotlib and matplotlib-base are one floor and one fix, and a key that took each half’s own word for the name would file exactly the two issues it exists to prevent. That table of pairs is written by hand, and a package added under two names would announce itself only by filing twice, the week that floor broke and not before, so the divergence is computed from the declarations themselves and gated (§5).

issues: write is granted on the filing step alone. Everything else runs under the repository’s default permissions: {} (spec §8.7).

4. Failures that are not a floor#

Two of them, both worth stating because the job’s output is an issue and a false one costs someone a reading.

A transient solve. Unlike every other job in this repository, this one solves fresh — that is the whole point of it — so it inherits the failure modes of a live channel: a mirror hiccup, a metadata fetch that times out. The job retries the solve once before treating a failure as a finding. A retry is not a fix for a flaky channel, but it is the difference between weekly noise and an occasional one.

setuptools-scm at its floor. Its failure mode is not “one package is wrong” but “nothing builds”: tephpy is installed editable into the pixi environments (spec §8.2), and a build backend that cannot build produces a failure in which every tier looks broken and the attribution pass of §3.4 has nothing to relax that helps. It is called out here so that a failure of this shape is recognised rather than diagnosed from scratch.

5. Testing#

The generator is ordinary Python and gets ordinary tests, in tests/test_floors.py per spec §8.5. What they must cover is the four guards of §3.2 — a non->= specifier reported rather than skipped, a tier yielding nothing failing rather than passing, an entry that is neither a floor nor a source refused, and one name over both of a tier’s tables refused — because those are the paths on which the job’s whole claim rests and none is reached by a run that succeeds. The routing of §3.1 is covered the same way and for the same reason: a floor declared in a pypi-dependencies table resolves and scans against the package index, and a lookup that asked the channel instead would answer, plausibly, out of a set the tier never installs from.

Three things about the two halves are covered because nothing else would notice them go wrong. That each half reads its floors from the site it installs from (§3.4), since a half walking the other’s list attributes nothing rather than failing. That a PyPI relaxation pins what the default resolution chose rather than dropping a bound, the second of which resolves lower and so relaxes nothing. And that the two sites’ diverging names and tiers (§3.1) are reconciled — the name table by a gate computed from the declarations themselves, so that a package added under two names is caught when it is added rather than the week it breaks. The failure mode all three share is a quiet one: a wrong issue, or none, on a job that runs weekly and is read once a quarter.

The §3.2 rule that no test reads the rewritten manifest is gated rather than left to be remembered, because it is a rule a test breaks by being written the obvious way: reading pyproject.toml from the working tree is how one asks what this repository declares, and it passes everywhere except the weekly job that rewrote the file underneath it. The gate reads the test sources rather than running them, so it names the reader that would fail instead of waiting a week for the tier to (#164). It separates a build of the path from a mention of it — a test naming pyproject.toml in a message or a comment is not reading it — since a gate that cannot tell those apart earns a relaxation the first time it is wrong.

The filters of §3.2 are covered against a canned index rather than against PyPI, since what they must be shown to reject is a release nobody publishes on purpose: one whose only files are yanked, one shut out by its requires-python, and one carrying nothing this platform and interpreter could install. Each is a release the lookup would otherwise return as a floor, where it reads exactly like a floor that resolved.

The module lives under .github/, which MANIFEST.in prunes, so the test guards on the repository being a checkout and imports the script by path, as the gate tests there already do. Guarding the module rather than the test is deliberate: an unguarded import fails collection on an unpacked sdist, taking the rest of the suite with it.

The workflow itself is verified by running it on workflow_dispatch before it is scheduled, and once against a deliberately impossible floor, so that the attribution pass, the scan and the issue body are each seen to work on a failure rather than only on a green run.

6. Non-goals#

  • Testing the ceiling. Rejected (2026-08-13) — that is the lockfile-update bot’s job in spec §8.7’s fast-follow list, which moves the resolved point upward. This one moves it down, and a job that did both would report a failure at either end with one exit code.

  • Every supported Python, and every platform. Rejected (2026-08-13) — the floors are a corner, not a matrix. tephpy builds linux-64 only (spec §8.2), and resolving the same floors against 3.13 and 3.14 tests points that are neither the corner nor the lockfile.

  • Raising a floor automatically. Rejected (2026-08-13) — the decision is user-visible and belongs to a person; §2 records why a bot pull request is the wrong shape for it.

  • Checking that a floor is not needlessly high. Rejected (2026-08-13) — a floor above the oldest working version costs users nothing they can observe, and looking for one means scanning downward through versions this project has said it does not support.