docs(compat): retire the 1.x condition telemetry cannot satisfy - #393
docs(compat): retire the 1.x condition telemetry cannot satisfy#393dobby-coder[bot] wants to merge 7 commits into
Conversation
`COMPATIBILITY.md` conditioned removal on telemetry showing no traffic in three places. For `@e4a/pg-js` 1.x that condition cannot be satisfied in either direction: 1.x never sends `X-POSTGUARD-CLIENT-VERSION` (zero occurrences in 1.11.0's `dist/index.mjs`, three in 2.4.0's), and it requests the same route set with the same start-request body as 2.x, so nothing separates its traffic from any other unidentified caller. - The `@e4a/pg-js` support-window bullet now retires `1.x` on the calendar rule already on the page: 12 months after `2.0.0` shipped on 2026-06-02, so 2027-06-02 at the earliest. - The support-window paragraph drops the "longer while telemetry still shows it" clause; the 12-month rule stands alone. - Deprecation steps 2 and 3 narrow rather than drop the telemetry condition. Step 2 says what `postguard_clients` can settle (routes, and identified clients) and what it cannot (which client and version sent a header-less request, which `e86ff19` now counts as `client="unknown"`). Step 3 keeps the telemetry gate where the thing being removed is observable, a route or a field, and drops it for a client version. Step 2's scrape pointer moves from postguard-ops#64, closed 2026-08-19, to the live postguard-ops#71. No version number, no reader row and no line inside the fenced block changes. Closes #382
…trigger The bullet gave two different retirement conditions one sentence apart. Its own first clause is "the last two majors", so `1.x` drops when `3.0.0` ships; the calendar rule below is "at least 12 months", a floor that never removes anything. 2027-06-02 stays as the floor it is, so a consumer planning a migration does not read it as the drop date. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two claims in the deprecation steps were wrong about `E4A.PostGuard`. The issue's premise, that nothing makes the .NET SDK send `X-POSTGUARD-CLIENT-VERSION`, predates the release that does: `E4A.PostGuard-v0.5.0` (2026-06-19) shipped "send X-POSTGUARD-CLIENT-VERSION on every request", `src/ClientVersion.cs` sends `dotnet,<framework>,pg-dotnet,<version>`, and `pg-pkg` allowlists both halves (`metrics.rs:37`, `:48`, asserted by `test_dotnet_sdk_host_is_not_other`). Step 2's "not measurable" bullet therefore no longer puts the whole of `E4A.PostGuard` in the `unknown` bucket, only the releases below 0.5.0 — which are still supported, because the window counts `0.x` as one line. The bullet's conclusion is unchanged: probes, scanners and direct HTTP callers keep that bucket from reaching zero. Step 3 said a client version is not observable, full stop, which waived the telemetry condition for every client that does send the header (`pg-dotnet`, `pg4ol`, `pg4tb`, `cli`, `pg-js` 2.x all carry a real `client_version`). The discriminator is now whether the client sends the header, so the condition still binds where it can be met. Also drops the four em-dashes these hunks had added, restoring the file's own count of two.
Step 2 said `pg-pkg` counts every `/v2` request. The Governor is registered after `collect_metrics` and so sits in front of it (`pg-pkg/src/server.rs`), which is the invariant `server.rs:411` already states: `collect_metrics` counts every `/v2` request that gets past the rate limiter. A 429 increments nothing, not even the `unknown` bucket. This is the one place in the file that enumerates what the metric settles, so the qualifier belongs here; the paragraph's argument is unchanged, since header-less traffic that is served is still visible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 3 said a client that sends the header "is counted under its own `client_version`, and the condition holds for it unchanged". That waived a qualifier the metric needs: `client_version_label` (`pg-pkg/src/middleware/metrics.rs:97`) returns `other` when the version field is misshapen, and when that client's `SEEN_CLIENT_VERSIONS` bucket has already created `MAX_CLIENT_VERSIONS` = 64 series, which the static never evicts before a restart. `@e4a/pg-js` has 55 published versions against that cap and the field is attacker-controlled, so an empty series for one version is not on its own zero traffic. Step 3 now says to read that client's `other` alongside the version's own series: zero on both is absence, a non-zero `other` means the version may be folded into it and the metric has not answered. Both branches are shipped behaviour, covered by `test_misshapen_client_version_becomes_other` and `test_client_version_cap_holds`. Also rewraps step 2's first sentence to the file's 80 columns, which the rate-limiter qualifier in ff27ef1 left short.
Step 2 named `1.x` as the concrete unidentifiable client and step 3's parenthetical repeated it, both on the issue's premise that the header arrived with the 2.x line. It arrived one minor later. Packing all 55 published tarballs and grepping for the header name: 0 occurrences in every version up to and including `2.0.2`, 7 from `2.1.0` on. So `2.0.x` shares the `client="unknown"` bucket with `1.x`, and unlike `1.x` it is not a line on its way out: the window is the last two majors, so a `2.0.x` retirement is one this metric cannot answer either. Both places now say `2.1.0` rather than naming a major. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two problems with the same sentence, both found by reading it as a maintainer about to retire a version. It said 55 published `@e4a/pg-js` versions leave 9 slots under the 64-series cap. A version only spends a slot by arriving in the header, and only 15 of the 55 send one, so real releases leave 49 free. The sum argued for a `MAX_CLIENT_VERSIONS` bump nobody needs. Dropped rather than corrected: the misshapen branch needs no cap to fire and the field is attacker-controlled, so `other` is worth reading either way, and one less constant transcribed out of `metrics.rs` is one less to drift. It also stopped a reading short. `client="pg-js"` at zero with its `other` at zero is still not absence, because the add-ins override the header wholesale and `pg-pkg` allowlists `pg4ol` and `pg4tb` as clients in their own right (`KNOWN_CLIENTS`). The add-in ships `@e4a/pg-js` 2.4.0 and reports `Outlook,1.0,pg4ol,<addin version>`, so that 2.4.0 touches neither series a maintainer was told to check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hand-over:
|
Closes #382.
COMPATIBILITY.mdconditioned removal on telemetry showing no traffic, in three places. For@e4a/pg-js1.x that condition cannot be satisfied in either direction, so this replaces it with what the metric can answer. No version number, no reader row and no line inside the fenced block moves: the diff hunks are at:90,:103,:181and:207, and the block sits at:125-130.The three edits
@e4a/pg-jssupport-window bullet retires1.xon the calendar rule already in that section, at least 12 months after its successor shipped. npm published2.0.0on 2026-06-02, so1.xstays in the window until 2027-06-02 at the earliest. The add-in note, theapps/outlook-addonpointer and thepostguard-outlook-addon#125record all stay. The clause that carried them opened with "The other half of that condition", which no longer has a first half, so it now reads "which retirement also used to wait on".[postguard-ops#64]reference. The 12-month rule stands alone.postguard_clientssettles, which routes are called and which identified clients call them (pathis the pattern actix matched against the server's own route table), from what it cannot: which client and which version sent a request carrying no header. Step 3 keeps the telemetry gate where the thing being removed is observable, a route or a field, and drops it for a client version, where the expired window and step 1's announcement are the whole condition.Two claims re-checked against
mainThe exporter landed after this ticket was filed, so step 2's "not measurable" bullet describes the code it lands on rather than the code it was written against.
pg-pkg/src/middleware/metrics.rscounts every/v2request, and a header that is absent, unreadable or not exactly four fields lands asclient="unknown"(UNKNOWNatmetrics.rs:18, thefields.unwrap_or_elsefallback atmetrics.rs:164). Header-less traffic is visible as traffic, and the bullet says so. What stays unavailable is who sent it:@e4a/pg-js1.x shares that bucket with probes, scanners, every direct HTTP caller andE4A.PostGuardbelow 0.5.0, which is why no measurement can retire the line.The issue's own measurement holds against the readers
pg-compat-jsinstalls.X-POSTGUARD-CLIENT-VERSIONappears 0 times innode_modules/pg-js-1-11-0/dist/index.mjsand 3 times in the 2.4.0 one.postguard-ops#64to#71Three occurrences in
COMPATIBILITY.mdat HEAD: the two uses at:103-104and:183, plus the link definition at:207. Edits 2 and 3 rewrite both uses, so the definition was this file's last one, and[postguard-ops#71]takes its position. One reference survives outside the file, the comment at.github/workflows/api-diff.yml:22; it is out of this issue's scope and outside what this account can push.I could not open either ops issue from here (this installation has no read access to
privacybydesign/postguard-ops), so #71's number and title are the amendment's, not the tracker's.Review fixes (6f30e66)
Two claims about
E4A.PostGuardwere wrong, both inherited from the issue's premise that nothing in the tree makes the .NET SDK send the header. That premise predates the release that does, so I checked it against the SDK rather than against this tree:E4A.PostGuard-v0.5.0(2026-06-19) shipped "send X-POSTGUARD-CLIENT-VERSION on every request" (postguard-dotnet#33).src/ClientVersion.csbuildsdotnet,<framework>,pg-dotnet,<version>andPostGuard.cs:49applies it.pg-pkgallowlists both halves deliberately:dotnetatmetrics.rs:37,pg-dotnetat:48, withtest_dotnet_sdk_host_is_not_otherassertingclient="pg-dotnet".So step 2's bullet now puts only the sub-
0.5.0releases in theunknownbucket. Those are still supported, because the window counts0.xas one line, so the bullet keeps a real .NET case rather than dropping the SDK from it. Its conclusion is untouched: probes, scanners and direct HTTP callers are what keep that bucket off zero.Step 3 said a client version is not observable with no qualifier, which waived the telemetry condition for every client that does send the header —
pg-dotnet,pg4ol,pg4tb,cliandpg-js2.x all land with a realclient_version, andE4A.PostGuardhas its own support-window bullet. The discriminator is now whether the client sends the header, so the condition still binds wherever it can be met, and is waived only where it cannot.Also dropped the four em-dashes these two hunks had added, putting the file back to the two it already had.
The third review point,
.github/workflows/api-diff.yml:22still readingpostguard-ops#64, is left alone on purpose: issue #382 puts.github/workflows/out of scope, and this app has noworkflowspermission to push it. It needs a separate human commit.Review fixes (868b0d7)
Step 3 waived a qualifier the metric needs. It said a client that sends the header "is counted under its own
client_version, and the condition holds for it unchanged", which reads a zero against a real version as zero traffic.client_version_label(pg-pkg/src/middleware/metrics.rs:97) does not promise that:other: empty, longer thanMAX_CLIENT_VERSION_LEN= 32, or carrying a byte outside[A-Za-z0-9.-].SEEN_CLIENT_VERSIONSbucket already holdsMAX_CLIENT_VERSIONS= 64 series. The static never evicts, so a full bucket only empties on a process restart, and@e4a/pg-jshas 55 published versions against that cap with an attacker-controlled field for the 9 slots left.So step 3 now tells the reader to read that client's
othernext to the version's own series: zero on both is absence, a non-zeroothermeans the version may be folded into it and the metric has not answered. The condition still binds for every client that sends the header, and is still waived only where no header arrives.Both branches are shipped behaviour rather than a prediction about it, each with a test in
pg-pkg/src/middleware/metrics.rs:test_misshapen_client_version_becomes_otherandtest_client_version_cap_holds.The same commit rewraps step 2's first sentence to the file's 80 columns, which
ff27ef1's rate-limiter qualifier left short. No wording changed there.One coupling this adds, stated rather than left to be found:
64and55now sit inCOMPATIBILITY.mdwith nothing checking them againstmetrics.rs. Both are cited besideMAX_CLIENT_VERSIONSby name, so a grep for the constant reaches the prose, and #382 rules out touchingpg-pkg/to add the test that would close it properly.The second point,
.github/workflows/api-diff.yml:22still readingpostguard-ops#64, stands where the previous round left it, for the same two reasons: #382 puts.github/workflows/out of scope, and this app has noworkflowspermission to push it.Testing
Both acceptance checks, against a sample set sealed by HEAD:
One correction to that second line, from running it in a clean container this round:
npm teston its own fails 5 of the 26 intest/wire-compat.test.mjs, before this change as well as after it, because the pinned readers resolve the sample set fromPG_COMPAT_ARTIFACTSand fall back to a path that does not exist. The invocation that passes is the one the test file documents attest/wire-compat.test.mjs:5-6:What a prose edit could have disturbed is the fenced block's two machine-readers, and both parse it and compare it against their own list:
the_support_window_is_the_crates_io_reader_list_in_compatibility_mdinpg-compat/tests/support_window.rs, withno_two_pinned_readers_share_a_minor_linealongside it, andthe support window is the npm reader list in COMPATIBILITY.mdinpg-compat-js/test/manifest.test.mjs.published_readers_open_the_head_sealed_sample_setandpublished JS readers open the HEAD-sealed sample setthen opened every case with the pinned readers.Both suites need the sample set sealed first, or seven of those tests fail on a missing
manifest.json, before this change as well as after it:Also green, though a markdown edit cannot reach it:
cargo test --workspace, 407 passed.All three re-run on
6f30e66:pg-compat18 passed,pg-compat-js26 passed, workspace 407 passed. The fenced reader block is byte-identical tomain's, and every version string in the file is an addition — no existing pin moved.All three re-run on
868b0d7:pg-compat18 passed (support_window2 of them),pg-compat-js26 passed (manifest.test.mjs7 of them, including the npm reader-list assertion), workspace 407 passed. The fenced block is still byte-identical tomain's, andcomm -23over the version-like tokens of both revisions is empty, so no pin was dropped by the rewrap.Review fixes (ab5c3b6)
Three findings, all three real. I re-measured each one before editing.
2.0.xis header-less too (590eae1). The premise both earlier roundsinherited from the issue was that the header arrived with the 2.x line. It
arrived one minor later. Packed all 55 published
@e4a/pg-jstarballs andgrepped each for the header name: 0 occurrences in every version through
2.0.2, 7 from2.1.0on. Step 2's bullet now dates the header to2.1.0andputs
1.xand2.0.xin theclient="unknown"bucket, and step 3'sparenthetical reads
below 2.1.0instead of naming a major. The bullet alsosays
2.0.xis inside the window, which is the part that bites: the window isthe last two majors, so this is a retirement the metric cannot answer for a
line that is not on its way out.
The 55/9 headroom sum was wrong by roughly 5x (ab5c3b6). A version spends a
slot in
SEEN_CLIENT_VERSIONSonly by arriving in the header, and only 15 ofthe 55 published versions send one, so real releases leave 49 of the 64 free
rather than 9. Dropped the arithmetic rather than correcting it: the
misshapen-field branch fires with no cap involved and the field is
attacker-controlled, so
otheris worth reading either way. That also retiresthe coupling the last round's body flagged, since
64and55are no longertranscribed out of
metrics.rswith nothing checking them.MAX_CLIENT_VERSIONSstays named, so a grep for the constant still reaches the prose. The same sum in
that static's own doc comment is pre-existing (#384) and stays put, because #382
rules out touching
pg-pkg/.Step 3's reader instruction stopped one client short (ab5c3b6).
client="pg-js"at zero with itsotherat zero is still not absence.Checked against
postguard-jsrather than reasoned about:apps/outlook-addon/src/lib/pkg-client.ts:20sendsOutlook,1.0,pg4ol,<addin version>,packages/pg-js/src/postguard-base.ts:29-31says an embedding host's header wins, the add-in is on
"@e4a/pg-js": "workspace:*"which resolves to2.4.0, andpg-pkgallowlistspg4olandpg4tbinKNOWN_CLIENTS(metrics.rs:46). So the 2.4.0 theadd-in ships lands under neither series a maintainer was told to read, and step
3 now names that as the second reading.
api-diff.yml:22is handed over in a comment on this PR: the full filefirst, then one
gh api -X PUTwith the base64 inlined, since this app cannotpush
.github/workflows/. Only the ticket number is stale there. That sentenceis about retiring
/v2, and a route is observable, so the gate it describessurvives step 3's narrowing.
Testing (ab5c3b6)
All three suites on the pushed head:
Both machine-readers of the fenced block ran green:
the_support_window_is_the_crates_io_reader_list_in_compatibility_mdwithno_two_pinned_readers_share_a_minor_linebeside it, andthe support window is the npm reader list in COMPATIBILITY.md(subtest 22 of 26). The block is stillbyte-identical to
main's, no version-like token frommainwas dropped, thefile still holds
main's two em-dashes and no more, and every new line isinside the file's 80 columns.
Reviewed by dobby: the one binding rule for this repo (
code-comments) over the markdown-onlyCOMPATIBILITY.mddiff — it carries no source-code comments, so the rule cannot fire and there are no rule findings — plus step 2's and step 3's new prose re-checked againstpg-pkg's actual middleware layering and metric labels onmain, which contradict two of its claims — builtpg-pkg,pg-compat3/3,pg-compat-js26/26,cargo test --workspace407/407, plus a scratch actix test reproducing the rate-limiter/counter layering, all passed — request-changes.Attention:
COMPATIBILITY.md:182-185's parenthetical, that an over-limit request "is rejected with 429 before it reaches the counter", is true only of the general/v2limiter and false for exactly the endpoints a deprecation would read —Governor::new(&general_ratelimit)is registered after.wrap_fn(collect_metrics)on the/v2scope so it sits outside the counter, but the stricter per-endpoint limiter is wrapped on resources inside that scope (pg-pkg/src/server.rs:295,:306,:323,:330,:346,:356) and its rejections are counted withstatus="429", and it is the one that fires in practice (2/s burst 10 against the general 10/s burst 50) on/request-start,/key,/key/{timestamp},/sign/keyand/api-key/validate.Attention:
COMPATIBILITY.md:205-206's "A route or a field is observable" is half wrong and contradicts step 2 four lines above it —postguard_clientscarriespath, host, client, client_version, status(pg-pkg/src/server.rs:29-33) and is the only metric family the PKG registers, so no label carries a/v2request or response field, and as written step 3 puts a telemetry gate on removing a field that the telemetry cannot open.Attention:
COMPATIBILITY.md:242'spostguard-ops#71could not be verified from this container either —privacybydesign/postguard-ops404s through the per-org token (the repo itself, not just the issue) and#64404s the same way — so a human with access needs to confirm#71is the scrape ticket before merge, since it is the only pointer step 2 leaves.Attention:
.github/workflows/api-diff.yml:22still readspostguard-ops#64whileCOMPATIBILITY.md:242now reads#71, so the repo's two references to the scrape ticket disagree; leaving it here is correct (issue #382 puts.github/workflows/out of scope and this app has noworkflowspermission to push it), so it needs the separate human commit already handed over in a PR comment.