From 39ba08b8fa5df59804f047b6b08c0671977651dd Mon Sep 17 00:00:00 2001 From: "Harol A. Reina H." Date: Thu, 24 Sep 2026 06:48:42 -0500 Subject: [PATCH] =?UTF-8?q?docs(decisions):=20only=20a=20`Changed=20?= =?UTF-8?q?=E2=80=94=20BREAKING`=20forces=20a=20minor=20(ADR-0061)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three accepted records say a behavioural break takes a minor, and the shipped history contradicts them twice. ADR-0028's post-v2.0 commitment 3 calls a patch "no-breaking por definición"; ADR-0050 says "never in a patch"; ADR-0052 F4 says a breaking entry "gets a minor bump". But 2.5.2 and 2.5.3, both 2026-09-13 and both after all three, each shipped two `Fixed — BREAKING` entries as a patch. Counted across every `BREAKING` heading in CHANGELOG.md: all three `Changed — BREAKING` live in 2.5.0, a minor, and none has ever shipped in a patch; fourteen `Fixed — BREAKING` shipped in both tiers. And 2.5.0's ten did not cause its minor — the three `Changed` in the same release already did. No release has ever taken a minor *for* a `Fixed — BREAKING`. The split is not arbitrary: `Fixed` says the documented behaviour was not being delivered and now is, so the consumer relied on something never promised; `Changed` withdraws a promise that was kept. D1 records that. D2 states the amendment to ADR-0028 commitment 3 explicitly rather than leaving it implicit — the rest of that ADR stands, including the 8-12 minors/year cap and the migration guide a breaking minor owes. D5 records that 2.5.2 and 2.5.3 are applications of the rule, not errors. The cost is stated, not mitigated: a label now carries the release tier and nothing verifies it. No test parses CHANGELOG.md and publish.yml never reads `[Unreleased]`. Also applied here, since it is the same ruling: #286's entry gains its `BREAKING` label. A save in flight at shutdown is now cut short when the budget expires instead of running under a token nothing could cancel — observable to an operator, which is the threshold #284 was labelled under. It is a `Fixed`, so it does not move the pending release's tier; 2.6.0 is already a minor from the two `Changed — BREAKING` it carries. ADR-0048 D7 reads like a third tier rule and is not: its "not a patch" means an inline code change, settled by its own next sentence, and the case it names shipped as a `Changed — BREAKING` in a minor anyway. ADR-0027's constraint on removing public MIT types is untouched. --- CHANGELOG.md | 2 +- README.md | 2 +- docs/claim-registry.md | 2 +- ...-only-a-changed-breaking-forces-a-minor.md | 139 ++++++++++++++++++ docs/decisions/README.md | 1 + 5 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 docs/decisions/0061-only-a-changed-breaking-forces-a-minor.md diff --git a/CHANGELOG.md b/CHANGELOG.md index bd8b0dd9..8b5eb5ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -173,7 +173,7 @@ most of them — and a pager tuned to that counter stops firing on normal call e the single write call, so none of them is absorbed. - No public API change. `voiceai.sessions.*` is unaffected — the exception never reached the session layer. -### Fixed — a session save in flight at shutdown was governed by a token only an aborted start could cancel (#286) +### Fixed — BREAKING: a session save in flight at shutdown was governed by a token only an aborted start could cancel (#286) `SessionManagerHostedService` stored the token the host hands `StartAsync` and gave it to `CallSessionManager`, which runs **every** session save under it — not only the ones at shutdown. That diff --git a/README.md b/README.md index aa577385..07138a74 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Release history — every version, with what changed and why — is in [`CHANGEL API coverage (cumulative): 148/152 AMI actions (97%), 94/98 ARI endpoints (96%), 46/46 ARI event types (100%), 27/27 ARI models (100%), 269 AMI events covering Asterisk 18-23. Asterisk 22.5+ outbound WebSocket and Asterisk 22.8/23.2+ `chan_websocket` JSON control protocol both supported. Compatible with **Asterisk 18, 20, 22 LTS, and 23 Standard** — see [`docs/guides/asterisk-version-matrix.md`](docs/guides/asterisk-version-matrix.md) for lifecycle and break-change risk areas. -Architecture decisions: **58 ADRs** in [`docs/decisions/`](docs/decisions/) covering AOT-first design, source-generator-over-reflection policy, three-tier test strategy, push-bus design, cadence commitment, resilience/cluster primitive split between MIT and Pro, the rebrand to Verbara, and the cross-repo ADR reference convention. +Architecture decisions: **59 ADRs** in [`docs/decisions/`](docs/decisions/) covering AOT-first design, source-generator-over-reflection policy, three-tier test strategy, push-bus design, cadence commitment, resilience/cluster primitive split between MIT and Pro, the rebrand to Verbara, and the cross-repo ADR reference convention. --- diff --git a/docs/claim-registry.md b/docs/claim-registry.md index fa766cbf..697cd883 100644 --- a/docs/claim-registry.md +++ b/docs/claim-registry.md @@ -55,7 +55,7 @@ is the worked example: its vendor wire captures are EVIDENCE, its counts of *our | ~~67~~ | 94.26% English accuracy, in upstream's voice | — | — | **DELETED** — lived in the release bullets, cut by the 2026-09-20 ruling on `README.md` release history. The figure survives at `:465` (row below) with its citation and hash pin; this was the duplicate. | | ~~67~~ | ~12 ms CPU inference | — | `TurnDetectionBenchmark` | **DELETED** — measured at 26.18–37.30 ms; deferred, see *Deferrals*. Its line was also removed with the release bullets on 2026-09-20. | | 65 | 148/152 AMI (97%), 94/98 ARI (96%), 46/46, 27/27, 269 events | ENFORCING | — | GAP — the event figure was corrected 2026-09-24 (was 278) | -| 67 | **58 ADRs** | ENFORCING | `StatusBlockCoherenceTests` — counts `docs/decisions/*.md`, excluding the catalog `README.md` | **OK** | +| 67 | **59 ADRs** | ENFORCING | `StatusBlockCoherenceTests` — counts `docs/decisions/*.md`, excluding the catalog `README.md` | **OK** | | 91 | measurement provenance (Ryzen 9 9900X, .NET 10.0.5, BDN v0.14.0, 2026-04-18) | COHERENCE | `PerformanceTableCoherenceTests` — the header provenance test | PARTIAL — matched against the whole file rather than this line, and blind to the AMI row's .NET 10.0.6 MediumRunJob exception (`performance-record.json:12`); the two session-store rows measured apart from it state their own at :110 | | 95 | AMI parse+dispatch 1.62M events/sec (617.6 ns) | ENFORCING + COHERENCE | `perf-regression.yml` `*AmiProtocolReader*` | GAP — observational only (`\|\| true`, no baseline) | | 96 | ARI deserialize Channel 3.54M ops/sec (283 ns) | ENFORCING + COHERENCE | `*AriJson*` | GAP — observational only | diff --git a/docs/decisions/0061-only-a-changed-breaking-forces-a-minor.md b/docs/decisions/0061-only-a-changed-breaking-forces-a-minor.md new file mode 100644 index 00000000..b3a8b6af --- /dev/null +++ b/docs/decisions/0061-only-a-changed-breaking-forces-a-minor.md @@ -0,0 +1,139 @@ +# ADR-0061: Only a `Changed — BREAKING` entry forces a minor + +- **Status:** Accepted +- **Date:** 2026-09-24 +- **Deciders:** Harol A. Reina H. +- **Related:** ADR-0028 (cadence commitment — **this ADR amends its post-v2.0 commitment 3**), + ADR-0050 (the first behavioural break released as a minor, and the origin of the written rule), + ADR-0052 F4 (restates it), ADR-0055 (the version is cut at release time, so a tier ruling binds the + release and not the diff), ADR-0060 (the most recent `Changed — BREAKING`, and the migration guide + its tier obliged), ADR-0048 D7 (reads as a tier rule and is not one — see *Context*), ADR-0027 (the + stewardship pledge, whose constraint on removing public MIT types this ADR does not touch). + Harvested from the change `ari-failed-connect-and-silent-catches`, task 9.5. + +## Context + +Three records in this repository say a behavioural break takes a minor, and the shipped history +contradicts them twice. + +What the records say: + +- **ADR-0028** (2026-04-20), post-v2.0 commitment 3: *"Patch cadence: mensual o por needs, + **no-breaking por definición**."* In force since v2.0.0 shipped on 2026-05-06. +- **ADR-0050** (2026-08-17): *"It ships in a minor with an explicit callout, never in a patch."* +- **ADR-0052 F4** (2026-08-19): *"it is a behavioural break and gets a `BREAKING` CHANGELOG entry and + a minor bump, matching how ADR-0050 itself shipped."* + +What shipped. Every `BREAKING` heading in `CHANGELOG.md`, counted 2026-09-24: + +| Release | Tier | `Fixed — BREAKING` | `Changed — BREAKING` | +|---|---|---|---| +| 2.5.0 (2026-08-24) | minor | 10 | 3 | +| 2.5.2 (2026-09-13) | **patch** | 2 | 0 | +| 2.5.3 (2026-09-13) | **patch** | 2 | 0 | +| `[Unreleased]` | — | 2 | 2 | + +Both patches landed after all three records. And 2.5.2's is not a distant cousin of the class those +ADRs were written about: `Fixed — BREAKING: AmiConnection.ConnectAsync completed as Connected when +cancelled` is the AMI sibling of the ARI connect-state fix that ADR-0056 later shipped. + +**The pattern nobody wrote down is narrower than any of the three records.** A +`Changed — BREAKING` has shipped three times and all three are in 2.5.0, a minor; it has never +shipped in a patch. A `Fixed — BREAKING` has shipped fourteen times, in both tiers. And 2.5.0's ten +did not cause its minor — the three `Changed — BREAKING` in the same release already forced it, so no +release in this repository's history has ever taken a minor *for* a `Fixed — BREAKING`. + +Four `BREAKING` markers appear outside a heading (`CHANGELOG.md:532`, `:1415`, `:1488`, `:1848`). +Each sits under a `Fixed — BREAKING` heading and elaborates it. No break has reached a release +without a heading declaring it, so the headings are a complete index of this history. + +**One record reads like a third tier rule and is not.** ADR-0048 D7 says *"a route fix that changes +the meaning of a public property is an API decision, not a patch"*. Its next sentence settles the +sense: *"never applied inline because the route is obviously wrong"* — "patch" there is an ad-hoc code +change, not a release tier. The case it names resolves the ambiguity anyway: `SpeechmaticsOptions.BaseUri` +shipped as one of 2.5.0's three `Changed — BREAKING` entries, in a minor, which is what D1 below +requires. A rule nobody had written was followed a fourth time. + +**Why the split is not arbitrary.** The two labels make different promises about what the consumer +was relying on. `Fixed` says the documented behaviour was not being delivered and now is: the +consumer who depended on the old behaviour depended on something the SDK never promised. `Changed` +says the documented behaviour itself moved: the consumer depended on exactly what was promised, and +the promise was withdrawn. Those are different debts, and the history priced them differently +without ever saying so. + +## Decision + +**D1 — Only a `Changed — BREAKING` entry forces a minor.** A release whose breaking entries are all +`Fixed — BREAKING` MAY ship as a patch. + +**D2 — This amends ADR-0028's post-v2.0 commitment 3.** *"No-breaking por definición"* now reads: a +patch carries no `Changed — BREAKING`. It may carry a `Fixed — BREAKING`, and when it does, the entry +keeps its label and its explicit callout — the tier is not what the label is for. The rest of +ADR-0028 stands untouched: the 8–12 minors/year cap, the annual major on the .NET cycle, the LTS +line, and the migration-guide obligation on a minor that carries a breaking change. ADR-0027's +constraint on removing public MIT types is untouched as well: this ADR prices behavioural breaks, and +says nothing about removing public surface, which is a separate promise under the stewardship pledge. + +**D3 — The label is the decision, and it is made when the entry is written**, by the author, against +what the SDK documented before the change. An entry that cannot say which documented behaviour it +restores is a `Changed`. + +**D4 — The tier binds the release, not the diff.** `Directory.Build.props` is cut at release time +(ADR-0055), so a ruling recorded against a pull request constrains the release that carries it and +changes nothing in that pull request. + +**D5 — 2.5.2 and 2.5.3 are applications of this rule, not errors.** Recorded here so a reader +comparing those two releases against ADR-0028, ADR-0050 or ADR-0052 does not have to reconstruct +whether the repository broke its own commitment. It did not; the commitment was written more +broadly than the practice it described. + +## Consequences + +- **Positive, and the point: a defect fix can reach consumers in a patch.** Fourteen of the + seventeen breaking entries this repository has released are `Fixed` — defects in provider + integrations, cancellation accounting and audio handling, none of which withdrew a promise the SDK + had kept. Pricing each at a minor would consume the 8–12 minors/year ADR-0028 caps on bug fixes, + and would make the class of fix ADR-0050 exists to encourage the most expensive kind to ship. + +- **Negative, and the headline: a label now carries the release tier, and nothing verifies it.** No + test in this repository parses `CHANGELOG.md`; `publish.yml` reads only `## []` headings + and never reads `[Unreleased]`. An author who wants a patch can write `Fixed` on an entry that + moves a documented behaviour, and no gate will notice. The mitigation is D3 plus the callout text + itself, which a reviewer can check against the diff — deliberately a review property rather than a + gate, because a parser over prose headings would fail in the direction of blocking a correct + release. + +- **The pending release is unaffected.** `[Unreleased]` holds two `Changed — BREAKING` (#302's + AudioSocket frame format and #291's `AriOutboundListener` accept behaviour), so 2.6.0 is a minor + under D1 — the same answer already ruled on that change, now reached by a stated rule instead of a + case-by-case reading. + +- **ADR-0028's migration-guide obligation is now the binding constraint on that cut, and it is not + yet met.** One guide exists, `docs/guides/audiosocket-wire-format-migration.md`, written for #302. + Recorded here as owed rather than resolved, because this ADR settles the tier and the tier is what + triggers the obligation. + +- **This gives no procedure for choosing `Fixed` over `Changed` on a given entry.** D3 states the + test — which documented behaviour does this restore — but the judgement stays with the author, and + it now costs a release tier instead of nothing. That is a real increase in the stakes of a label + that used to be presentational. + +## Alternatives considered + +1. **Reaffirm ADR-0028 as written, and record 2.5.2 and 2.5.3 as two violations.** Rejected. + Fourteen of the seventeen breaking entries this repository has released are `Fixed` — the count + excludes `[Unreleased]`. Under this reading each one costs a minor, the cap ADR-0028 sets is + consumed by bug fixes, and the incentive runs against shipping exactly the fixes ADR-0050 was + written to encourage. It also leaves two shipped releases standing as errors that no consumer was + harmed by. + +2. **Declare that every breaking change takes a minor, in one place, and delete the three + scattered statements.** Same effect as 1, with tidier records. Rejected for the same reason. + +3. **Decide the tier per release by measured blast radius.** Rejected: no written procedure, nothing + to check a past release against, and the measurement would be made by the same author whose label + it replaces. + +4. **Leave it unwritten and keep ruling case by case.** Rejected: the contradiction now sits in three + accepted records and two shipped releases, and the next reader who finds it has to redo the + reading this ADR is the result of. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index be6ed57c..06b9388d 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -106,3 +106,4 @@ The decision, stated in one or two sentences. - [ADR-0058](0058-a-stopping-token-never-gates-a-handoff.md) — A token that stops a server never gates a hand-off carrying ownership of a resource the server has already acquired: the hand-off is unconditional, `Task.Run`'s own token is an explicit `CancellationToken.None` (`CA2016` is an error here, so the bare call does not compile), and the stopping token travels *into* the handler, which is the single owner that closes the connection — quietly, because such a connection missed no deadline. ADR-0053's one-owner rule extended to the connection that has not become a session yet; the two accept loops in this repo now hand over with a line identical character for character, and `IDISP016` already refuses the rejected alternative at build time. (Accepted, 2026-09-21) - [ADR-0059](0059-a-lifetime-token-is-cancelled-by-the-phase-it-names.md) — A token a component keeps past a phase comes from a source that outlives that phase, and is cancelled by the phase it names: `SessionManagerHostedService` owns the source behind the token every `CallSessionManager` save runs under, instead of storing the host's `StartAsync` token — whose documented meaning is that the start was aborted, and which is provably inert after a completed start because the host releases it there, so #261's shutdown filter could never fire in the phase it was written for. The stop phase cancels the source through a registration rather than on entry, the detach precedes that registration (load-bearing, and unbound until a test proved red against the mutation bound it), and `Dispose` cancels before releasing behind an idempotency gate. ADR-0054's one-owner rule read across a lifetime boundary. (Accepted, 2026-09-21) - [ADR-0060](0060-the-wire-format-is-asterisks-and-no-parser-is-held-to-its-own-encoder.md) — The AudioSocket frame is Asterisk's, not ours: **one byte of type, two of big-endian length**, with the values `res_audiosocket.h` defines (`Hangup 0x00`, `Uuid 0x01`, `Dtmf 0x03`, `Audio 0x10`, `Error 0xFF`); both of this repo's codecs read a four-byte header and took `0x01` for audio, so **neither server had ever completed a handshake**, and both suites stayed green for six months because every test built its input with the codec under test. **No parser may be held to its own encoder**: the format is pinned by bytes captured from a real Asterisk, in **one** fixture shared by every parser (a per-package copy is the same loop reassembled), the fixture grows only from a capture — which is why it has no hangup frame — and a real Asterisk dials both servers in the functional lane. Supersedes ADR-0017. Enum values change on shipped public types: breaking, minor not patch. (Accepted, 2026-09-23) +- [ADR-0061](0061-only-a-changed-breaking-forces-a-minor.md) — The release tier is set by **which kind** of breaking entry a release carries, not by the fact that something broke: a `Changed — BREAKING` forces a minor, a release whose breaking entries are all `Fixed — BREAKING` may be a patch. The labels promise different things — `Fixed` says the documented behaviour was not being delivered and now is, so the consumer relied on something never promised; `Changed` withdraws a promise that was kept. Measured across every `BREAKING` heading in `CHANGELOG.md`: all three `Changed — BREAKING` are in 2.5.0, a minor, and none has ever shipped in a patch, while fourteen `Fixed — BREAKING` shipped in both tiers — and 2.5.0's ten did not cause its minor, the three `Changed` did. **Amends ADR-0028's post-v2.0 commitment 3** (*a patch is no-breaking por definición*), which turns 2.5.2 and 2.5.3 from contradictions into applications; the rest of ADR-0028 stands, including the migration guide a minor owes. The cost is stated rather than mitigated: a label now carries the tier and nothing parses `CHANGELOG.md` to check it. (Accepted, 2026-09-24)