diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 36f4943..7480765 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -44,3 +44,26 @@ jobs:
- run: python -m pip install --upgrade pip
- run: python -m pip install . pytest
- run: python -m pytest -q
+
+ # One name for the branch ruleset to require, instead of the six matrix jobs.
+ #
+ # A required status check is matched by name, and a name that stops being
+ # produced never reports - so a pull request waits on it forever. Requiring
+ # `test (3.9, ubuntu-latest)` directly means that dropping 3.9 from the matrix
+ # above silently blocks every pull request in the repository, and the ruleset
+ # is somewhere nobody edits when they edit this file. This job's name does not
+ # depend on the matrix, so the two cannot drift.
+ #
+ # `if: always()` with an explicit result check, and not a bare `needs:`. A
+ # skipped or cancelled dependency leaves a plain `needs:` job skipped rather
+ # than failed, and a skipped required check is treated as passing - which is
+ # the same hole in the other direction.
+ ci-ok:
+ if: always()
+ needs: test
+ runs-on: ubuntu-latest
+ steps:
+ - name: every matrix job succeeded
+ run: |
+ echo "test -> ${{ needs.test.result }}"
+ test "${{ needs.test.result }}" = "success"
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..6c75407
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,90 @@
+# Changelog
+
+Written against the tags and the PyPI upload records rather than from memory, on
+2026-09-04. Release times are UTC as PyPI recorded them.
+
+A note on how entries here are worded, because it is the same rule the library
+itself is built on: a change to what the gateway is believed to accept carries
+the probe that established it and the date it was run. "The vendor's documentation
+says so" is not one of those, and an entry that rests on it says so outright.
+
+## Unreleased
+
+- `Source` and `Issues` added to the package metadata, and the README gained a
+ CI badge, a link to its own LICENSE and a link to the benchmark harness the
+ retry table is measured on. That is five things and they were held back for
+ two versions of one reason: the first four point into this repository, which
+ is internal and answers 404 to anyone not signed in - the badge reads "repo or
+ workflow not found" for the same reason - and the fifth points at
+ `proxy-benchmark`, which was internal until 2026-09-01.
+- The quickstart now shows a live first request through **any** proxy, not only
+ through NodeMaven's gateway. Nothing in the library changed - the capability
+ was there and the README buried it in the last section.
+- A `Parameters` reference for the shipped gateway, and an `Errors` list.
+
+## 0.1.2 - 2026-08-26 09:23
+
+- **`values`**, a per-parameter list of legal values, added to the provider
+ schema. A value outside the list is refused before anything is sent. It ships
+ empty for NodeMaven on purpose: what was known was which values the vendor's
+ proxy generator *emits*, which is a sample of one interface's output and not
+ the set the gateway accepts. A wrong entry here blocks a setting that would
+ have worked, which is worse than the gap it closes.
+
+ The key exists before there is anything to put in it because four language
+ SDKs read this schema: filling it in later is an edit to a data file, adding
+ the key later is an edit to four parsers.
+
+- The gateway's known parameters and their reactions were re-recorded in the
+ shipped definition with the probes and dates behind them.
+
+**`norotate` never shipped, and the round trip is the reason this file exists.**
+It was added to the known parameter list on 2026-08-21 because it appears in the
+vendor's own proxy generator, and removed on 2026-08-26 after being probed: three
+ways, each with a negative control carrying a name nobody could have implemented
+and a positive control on `filter`. It takes a junk value with 200 exactly as the
+unknown name does; with no session id it draws 6 distinct exits of 6, exactly as
+both controls; with a fixed session id and `ttl=1m` sampled at 0, 78 and 157
+seconds it draws 3 distinct of 3, again exactly as both controls. The gateway
+answers 200 and drops it.
+
+Both changes sat between the 0.1.1 and 0.1.2 tags, so no release ever accepted
+the parameter. What was wrong for those five days was the reasoning, not a
+published artifact: an unrecognised name and a name we have not heard of produce
+identical evidence on this gateway, and the vendor's file was allowed to break
+the tie instead of a probe.
+
+## 0.1.1 - 2026-08-20 20:58
+
+No change to the library. The version exists because the README shipped an
+example that did not run, and a README is the package page.
+
+- The separator rule was restated as a measurement rather than a precaution. On
+ 2026-08-20 a probe opened tunnels with `sid-order8e3bf9-4417` and with
+ `sid-order8e3bf9`, four rounds each, interleaved; both landed on **one** exit
+ address, while a third arm spelling `sid-order8e3bf94417` held a different one
+ throughout. The gateway cuts the value at the separator, so every order id
+ beginning `order` would quietly share one session and one exit.
+
+## 0.1.0 - 2026-08-20 19:42
+
+First release.
+
+- `Proxy`: builds the gateway username, and refuses input the gateway would
+ mishandle. Credentials fall back to the environment. `url()`, `requests()`,
+ `httpx()` and `playwright()` for the clients; `replace()` and `session()`
+ return new objects, because on this gateway the session key is the whole
+ parameter set and changing one parameter is a different exit address rather
+ than an adjustment.
+- `Provider`, `load()`, `load_file()`, `available()`: a gateway dialect is a
+ TOML file and never a code path, so a definition you write yourself goes
+ through the same builder and the same validation as the shipped one.
+- `NodeMavenError` and the three exceptions under it.
+- No socket, no session, no connection pool, and **no retry**. The last one is
+ the only design decision here taken against a measurement rather than a
+ preference: over 1464 attempts the chance the next one succeeds falls from 75%
+ with no prior failure to 5.8% after five and 0.5% after seven.
+
+This one was uploaded with an API token, before Trusted Publishing was set up.
+That token was revoked and no token is stored anywhere now; every release since
+is published by the `publish` workflow against an OIDC identity.
diff --git a/README.md b/README.md
index 5ad3e81..462c296 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +1,57 @@
-
+ `img src`, `height` and the badges all survive its sanitiser. -->

# nodemaven
**Builds the proxy username a gateway expects, and refuses the input it would silently drop.**
-
+
[](https://pypi.org/project/nodemaven/)
[](https://pypi.org/project/nodemaven/)
+[](https://github.com/nodemaven/nodemaven-python/actions/workflows/ci.yml)
+ dead link readme_renderer showed in this file. The badge keeps pointing at
+ opensource.org because it is a claim about the licence rather than about this
+ repository; the file itself is linked from the License section at the bottom,
+ which it could not be while the repository was internal. -->
[](https://opensource.org/licenses/MIT)
-[Quickstart](#quickstart) · [Sticky sessions](#sticky-sessions) · [Why the validation is the point](#why-the-validation-is-the-point) · [What it does not do](#what-this-library-does-not-do) · [Docs](https://docs.nodemaven.com?utm_source=github&utm_medium=sdk_python&utm_campaign=readme)
+[Quickstart](#quickstart) · [Sticky sessions](#sticky-sessions) · [Why the validation is the point](#why-the-validation-is-the-point) · [What it does not do](#what-this-library-does-not-do) · [Other gateways](#other-gateways) · [Docs](https://docs.nodemaven.com?utm_source=github&utm_medium=sdk_python&utm_campaign=readme)
@@ -57,9 +78,6 @@ the account you sign in with. The other option there is IP whitelisting, which n
credentials in the username at all; both are described in
[authentication methods](https://docs.nodemaven.com/en/articles/9979031-authentication-methods).
-Everything below except this first snippet builds strings offline and runs without an
-account.
-
```python
import requests
from nodemaven import Proxy
@@ -71,6 +89,41 @@ r = requests.get("https://api.ipify.org", proxies=proxy.requests())
print(r.text)
```
+
+
+**No account? Any proxy you already have works.** A gateway is a data
+description, not a code path, so one that ships no definition here goes through
+the same builder and the same validation:
+
+```python
+import requests
+from nodemaven import Proxy, Provider
+
+# An empty known_params is not a stub. It says nobody has established what this
+# gateway recognises, so every parameter is refused rather than sent to be
+# silently dropped - see "Why the validation is the point" below.
+mine = Provider(id="mine", label="My proxy", known_params=frozenset())
+
+proxy = Proxy(provider=mine, login="your-login", password="your-password",
+ host="proxy.example.com", port=8000)
+
+r = requests.get("https://api.ipify.org", proxies=proxy.requests())
+print(r.text)
+```
+
+Describe the parameters it does take and it validates those too - see
+[Other gateways](#other-gateways). Everything from here to the end of that
+section builds strings offline and opens no socket at all.
+
Credentials can come from the environment instead, so nothing is in your source:
```python
@@ -99,6 +152,63 @@ with sync_playwright() as p:
context = browser.new_context(proxy=proxy.playwright())
```
+## Parameters
+
+
+
+What the shipped NodeMaven definition accepts. Every name here was confirmed
+against the gateway rather than transcribed:
+
+| parameter | what it selects | values measured to work |
+|---|---|---|
+| `country` | country code, or `any` | `us`, `de`, ... |
+| `region` | area inside the country | a name |
+| `city` | city inside the country | a name |
+| `isp` | the exit's ISP | a name |
+| `sid` | the sticky session - see below | any string with no `-` |
+| `ttl` | how long that session is held | `10m`, `1m` |
+| `filter` | IP quality | `low`, `medium`, `high` (2026-08-13) |
+| `speed` | connection speed class | `fast`, `slow` (2026-08-12) |
+| `ipv4` | force IPv4 | `True` / `False` |
+
+**Names are validated. Values, on this gateway, are not.** Passing a name that is
+not in this table raises before anything is sent, because the gateway answers an
+unknown name with 200 and drops the setting. Values are passed through, because
+what is known is which ones have been observed to work - and that is not the same
+as the set the gateway accepts. Refusing on a guessed list would block a setting
+that would have worked, which is the worse mistake of the two. The schema does
+carry a per-parameter list of legal values and refuses anything outside it; the
+shipped definition leaves that list empty for every parameter, deliberately, and
+a definition you write yourself gets the check as soon as you fill it in.
+
+Credentials come from `NODEMAVEN_LOGIN` and `NODEMAVEN_PASSWORD` when not passed
+in, and the gateway address from `NODEMAVEN_HOST` and `NODEMAVEN_PORT`.
+
+## Errors
+
+All four inherit from `NodeMavenError`, so one `except` catches everything this
+library raises.
+
+| exception | raised when |
+|---|---|
+| `ParamError` | a parameter name is unknown, a value is empty, a value contains the gateway's separator, or a value is outside a list the definition declares |
+| `CredentialsError` | no login, no password, or no gateway address, from arguments or environment |
+| `ProviderError` | a gateway definition is missing, unreadable, or internally inconsistent |
+| `NodeMavenError` | the base, never raised on its own |
+
+`ParamError` also covers the two structural cases: `session()` on a definition
+that declares no session parameter, and a definition whose parameter names
+collide with `login`, `password`, `host`, `port` or `provider`.
+
+Nothing here is raised from a response, because nothing here sends one. Every
+failure this library reports is a failure it found before a socket existed.
+
## Sticky sessions
One `Proxy` is one identity. Pin it to a sticky session:
@@ -183,23 +293,62 @@ attempts per delivered page, against 1.7 in a healthy session. A library that
shipped automatic retry as a default would be spending that on your behalf
without telling you.
+
+
+Those 1464 attempts, and the cells they came from, are in
+[nodemaven/proxy-benchmark](https://github.com/nodemaven/proxy-benchmark) - the
+harness that measured them, open source, so the table above can be re-run rather
+than believed.
+
It also does not own an HTTP client, a connection pool or a browser. Those are
yours, and they are better than anything a vendor SDK would bundle.
## Other gateways
-Parameters are data, not hardcoded keywords. A gateway is one TOML file - its
-prefix, separators, session parameter and the set of parameter names it actually
-recognises - and a definition read from disk goes through the same builder and
-the same validation as the one shipped here:
+Parameters are data, not hardcoded keywords. A gateway is its prefix,
+separators, session parameter and the set of parameter names it actually
+recognises - and a definition written by you goes through the same builder and
+the same validation as the one shipped here. Either build it in place, as in the
+[quickstart](#quickstart), or keep it in a TOML file:
+
+```toml
+# my-gateway.toml
+label = "My proxy"
+known_params = ["country", "session"]
+session_param = "session"
+host = "proxy.example.com"
+port = 8000
+```
```python
from nodemaven import Proxy, load_file
mine = load_file("my-gateway.toml")
proxy = Proxy(provider=mine, login="u", password="p", country="us")
+proxy.session("order4417") # u-country-us-session-order4417
```
+`known_params` is the whole point of the file: name a parameter that is not in
+it and the call raises instead of connecting. Leave the list empty and every
+parameter is refused, which is the correct thing to say about a gateway whose
+dialect nobody has established.
+
+Credentials fall back to the environment under the definition's id in upper case,
+so this one reads `MY_GATEWAY_LOGIN` and `MY_GATEWAY_PASSWORD` and never
+`NODEMAVEN_*`. One process can hold several gateways without their credentials
+reaching each other.
+
+**The id comes from the filename, not from the variable you assign it to.**
+`load_file("my-gateway.toml")` is `my-gateway` however it is named in your code,
+and `-` becomes `_` in the variable names. Pass `provider_id=` to say it
+outright. The error raised when a credential is missing prints the exact pair it
+looked for, so this is one guess you never have to make.
+
Every definition carries a `status`. `measured` means traffic has gone through
that gateway and the dialect was read off the wire. `documented` means it was
transcribed from documentation and never exercised. Only `nodemaven` is shipped
@@ -209,6 +358,17 @@ here, and it is `measured`.
Python 3.9 or newer. No dependencies on 3.11 and newer; `tomli` on older ones.
+## Changes
+
+[CHANGELOG.md](https://github.com/nodemaven/nodemaven-python/blob/main/CHANGELOG.md).
+Entries carry the probe and the date behind any change to what the gateway is
+believed to accept.
+
## License
-MIT.
+
+
+[MIT](https://github.com/nodemaven/nodemaven-python/blob/main/LICENSE).
diff --git a/pyproject.toml b/pyproject.toml
index 94a5f5a..571d0a9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,12 +29,21 @@ dependencies = ["tomli>=1.1.0; python_version < '3.11'"]
[project.urls]
# These render as the sidebar on the PyPI page, so every one of them has to answer
-# 200 to a logged-out visitor. Checked 2026-08-25.
-# `Source` is deliberately absent: nodemaven/nodemaven-python is internal and
-# answers 404 anonymously, exactly as a repository that does not exist does. Add it
-# in the same commit that makes the repository public, not before.
+# 200 to a logged-out visitor.
+#
+# `Source` and `Issues` were held back while nodemaven/nodemaven-python was
+# internal, because an internal repository answers 404 to an anonymous visitor
+# exactly as a repository that does not exist does. They go in with the commit
+# that makes it public and not before.
+#
+# **They will not appear on the package page that is up now.** PyPI bakes this
+# table into each uploaded distribution and never re-reads the repository, so
+# 0.1.2's sidebar is fixed whatever this file says. The links arrive with the
+# next release; the visibility flip on its own does nothing to them.
Homepage = "https://nodemaven.com"
Documentation = "https://docs.nodemaven.com"
+Source = "https://github.com/nodemaven/nodemaven-python"
+Issues = "https://github.com/nodemaven/nodemaven-python/issues"
[tool.setuptools.dynamic]
version = { attr = "nodemaven.__version__" }
diff --git a/src/nodemaven/providers.py b/src/nodemaven/providers.py
index 0023247..f5f4356 100644
--- a/src/nodemaven/providers.py
+++ b/src/nodemaven/providers.py
@@ -87,6 +87,12 @@ def allowed(self, name: str) -> Optional[Tuple[str, ...]]:
@property
def is_measured(self) -> bool:
+ """Whether traffic has actually gone through this gateway.
+
+ False covers both "transcribed from documentation" and any status
+ nobody has defined, which is the safe way round: a definition is
+ unverified until something says otherwise.
+ """
return self.status == "measured"
diff --git a/src/nodemaven/proxy.py b/src/nodemaven/proxy.py
index 050cbf8..bf5c7f6 100644
--- a/src/nodemaven/proxy.py
+++ b/src/nodemaven/proxy.py
@@ -103,6 +103,13 @@ def __init__(
@property
def provider(self) -> Provider:
+ """The gateway definition this identity was built against.
+
+ Exposed because it answers the two questions a caller cannot otherwise
+ ask: ``provider.known_params`` is what this object will accept, and
+ ``provider.is_measured`` is whether anyone has confirmed the dialect
+ against the gateway or only read it out of documentation.
+ """
return self._provider
@property