The H-G1 dependency cap is a workspace-wide default, with recorded exemptions¶
Status: Accepted (2026-08-25)
Every third-party requirement declared by a package under packages/ carries an upper bound —
in [project].dependencies and in every [project.optional-dependencies] extra alike — unless the
pair (package, distribution) appears in the exemption table of
packages/latence-core/tests/test_dependency_caps.py
with a reason. That test is the rule; the manifest comments are commentary on it.
The scope question this decision settles is the one the convention had left open: the cap is not a rule about heavy model libraries. It is a default that applies to every third-party pin, and the model libraries are simply where it was applied first.
Context¶
The launch-hygiene review asked for "conservative upper caps … on the model-lib deps in the provider packages that import them, so a downstream install can't silently pull an API-breaking major", and explicitly said to "leave the stable libs (pydantic/pyarrow/fsspec) as-is". Eleven manifests grew a comment saying exactly that. Nothing read those comments.
A survey of the 34 package manifests found 25 uncapped third-party pins across 10 packages in
[project].dependencies — and 17 more in extras, which the survey had not thought to look at.
42 uncapped pins across 14 packages, against 26 capped ones. The convention had not merely been
applied unevenly; it had already failed at its stated job, and the lock files say so:
| Pin | Resolved to | The crossing |
|---|---|---|
pypdfium2>=4.0 |
5.12.1 (deploy/env/*/requirements.lock) |
major 4 → 5 |
neo4j>=5.0 |
6.2.0 (uv.lock) |
major 5 → 6 |
numpy>=1.24 |
2.4.6 (uv.lock) |
major 1 → 2 |
apache-airflow>=2.7 |
3.3.0 (uv.lock) |
major 2 → 3 |
python-pptx>=0.6.23 |
1.0.2 (uv.lock) |
0.x → 1.0 |
Five silent major crossings, none of them a model library, every one of them the precise event the
comment claims the convention prevents. A convention that only fires where somebody remembered to
type it is not a convention; ADR-0064 landing protobuf/sentencepiece caps in latence-gliner25
only because a reviewer happened to look is the same finding from the other direction.
Decision¶
1. The default is: cap it. Every third-party requirement in every packages/*/pyproject.toml
carries an upper bound. Extras are in scope because pip install latence-retrieval[graph-neo4j] is a
downstream install with identical exposure; a gate that covered dependencies alone would report
green over nine uncapped pins.
2. Exemptions are named, reasoned, and machine-read. They live in the EXEMPT table of the
gate, keyed by (package, distribution) — not by distribution alone, because transformers is
correctly capped in latence-splade and latence-disambig-glinker and correctly uncapped in the two
in-process VLM Parsers. The gate checks the table in both directions: an uncapped pin missing
from it fails, and an entry whose pin has since been capped or deleted also fails, so an exemption
cannot outlive the reason it was written for.
3. The cap sits at the boundary the distribution's own history supports, never at an invented
one. For a >=1.0 distribution that is the next major. For a 0.x distribution it is 1.0 —
the one break every 0.x library signals — unless this repository has recorded evidence that the
library breaks across 0.x minors, which is why gliner is <0.3, glinker is <0.2 and
flashdeberta is <0.1 while pdfplumber and peft are <1. H-G1's closing instruction, "if a
cap would be guesswork about the next major's compatibility, note it and skip rather than pin
arbitrarily", is preserved exactly: the note is now an EXEMPT entry instead of a comment.
The five exemptions this lands with:
| Package | Distribution | Why |
|---|---|---|
latence-core |
fsspec |
CalVer (2026.6.0). The leading component is a year; <N would encode a date. |
latence-core |
pyarrow |
The >=15.0 floor has spanned 15 → 24 → 25 with no source change, over the stable Table/Parquet surface. A cap in the near-zero-dep base (ADR-0016) every adopter installs beside their own Arrow stack costs a real resolution conflict to buy an unobserved boundary. |
latence-core |
packaging |
Same shape (23 → 26), sharper cost: the ecosystem's most ubiquitous build dependency, reached through one lazy import. A cap conflicts with pip/build/setuptools rather than protecting anything. |
latence-parser-lighton |
transformers |
The compatible major is genuinely unsettled — see below. |
latence-parser-glm |
transformers |
Same unsettled split, same named refusal. |
4. transformers in the two in-process VLM Parsers stays uncapped, and that is load-bearing.
deploy/env/gpu-learned/environment.yaml records that the >=5 floor for LightOnOCR comes from the
issue #198 pod field evidence and "NOT from independently verified package metadata", while
deploy/env/served-ocr-client/requirements.lock resolves the same package next to
transformers==4.57.6. <5 would contradict the field evidence; <6 would invent a boundary
nobody has tested. That spec's excluded_packages block names this uncapped pin three times as
the reason the refusal has to be by name — "the incompatibility lives in the model code, not the
package metadata" — so capping it would not merely guess, it would falsify a recorded reason and
turn a resolution-time refusal into decoration.
5. This narrows H-G1 §3's "leave the stable libs as-is", and says so. pydantic, pyyaml and
typer are now capped. pydantic earns it on the merits rather than by category: the entire
contract spine — contracts.py, SCHEMA_VERSION, the validation at every Stage boundary — is
written against the 2.x model API, 2 → 3 is a signalled break, and <3 is what essentially every
pydantic consumer already declares, so it introduces no conflict an adopter does not already have.
Of the three distributions H-G1 named, the two whose "stable" claim survives contact with the
evidence — pyarrow and fsspec — are exempt, on that evidence rather than on the label.
Alternatives considered¶
A gate that knows the heavy model libraries (gliner/gliner2/glinker/flashdeberta/
transformers/torch) and checks only those. Rejected: it fails open on exactly the case that
produced this ticket. Every one of the five silent major crossings above involves a distribution
that list does not contain, so the gate would have shipped green over all of them, and would go on
passing vacuously for the next dependency somebody adds. A gate whose coverage has to be extended by
hand every time the workspace grows is the comment-only convention again, wearing a test's clothes.
The default-cap form fails closed: a new uncapped pin is red until somebody caps it or writes down
why they cannot.
Cap everything, no exemptions at all. Rejected as dishonest in two directions. It would force a
<5 or <6 on transformers in the VLM Parsers — a fabricated compatibility claim, and one that
contradicts a recorded field observation — and it would force a calendar date onto CalVer fsspec.
An exemption with a checkable reason is the honest encoding of "we do not know"; an invented cap is
not.
Keep it a convention and add it to the review checklist. Rejected on the evidence: it was a convention, it was documented in eleven manifests, and it still admitted five silent major crossings and 42 uncapped pins. This repository's standing answer to a claim nobody checks is to make a test check it (AGENTS.md § Six things that will burn you, item 4); nothing about this claim justifies an exception.
Enforce it against a lock file instead of the manifests — assert the resolved version still matches the declared floor. Rejected: the lock is a consequence of the manifests, so the check would fire only after a bad resolution had already happened, which is precisely the runtime surprise the cap exists to convert into a resolution-time refusal. The manifest is where the claim lives, so the manifest is where the gate reads.
Consequences¶
37 pins across 14 packages gained an upper bound, leaving the workspace at 63 capped
third-party pins and the 5 exemptions above. deploy/env/*/requirements.in and constraints.txt
are regenerated from the manifests by latence env render, and latence env verify proves all
four environment locks still satisfy every new cap — no cap sits below a version this workspace has
already resolved.
Three places outside the manifests restated a pin and had to follow it: the served-ocr-client
pillow constraint (whose reason said in prose that there was deliberately no upper cap),
deploy/Dockerfile's hand-written light-dep install line (the two OCR-client packages go in
--no-deps, so the image would otherwise have installed the uncapped resolution the packages now
refuse), and four test_envspec.py fixtures that inject drift by string-replacing a pin. Three of
those fixtures failed loudly on the cap; the fourth asserts an absence and so had gone silently
vacuous — worth recording, because it is the failure mode this ADR is about, one layer down.
The recurring cost is real and small: a genuine major bump now needs a one-line manifest edit with
the lock as its evidence, rather than arriving unannounced in an adopter's pip install. The
exemption table is the pressure valve, and it is deliberately uncomfortable to use — it wants a
reason a reviewer can check, and the gate deletes it the moment it stops being true.