Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **§19 `TelemetryEvent::ConfigClamped` (contract 1.9).** The SDK now reports a clamped
setting at construction rather than applying it silently — currently the §17.1 rule 2 memo
TTL. Clamping is right; clamping *silently* is not: an operator who set a 60-second TTL
believes their staleness bound is 60 seconds, and it is five. Nothing is emitted for a value
already within its limit, or for the disabled default — an event that fires when nothing
happened trains its reader to ignore it.

### Changed

- Re-vendored `CONTRACT.md` at **1.9**.

## [Unreleased]

### Added

- **§16 bounded read-only retry policy.** §11.2 rule 5 and §14.2 rule 6 had both been
*requiring* retries "under the SDK's existing bounded read-only retry policy" while no
such policy existed in the contract; this crate's improvisation was `backon`'s defaults
Expand Down
64 changes: 58 additions & 6 deletions CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1933,11 +1933,45 @@ with no attempt to refine it.
Two earlier clauses — [§11.2](#§112-semantics-normative-identical-in-all-sdks) rule 5 and
[§14.2](#§142-polling-normative--the-part-implementations-get-wrong) — instruct SDKs to retry
"under the SDK's existing bounded read-only retry policy". **No such policy was ever defined
here.** In practice two SDKs had one and they disagreed (Java: 3 attempts, 200 ms base, 5 s
cap, full jitter, `Retry-After` honored; Rust: 3 attempts, library-default backoff, no jitter,
no `Retry-After`), and the other nine had none at all — only §9's refresh-then-retry-once,
which is a different mechanism entirely. This section is that missing policy, so the two
forward references resolve to one table instead of eleven guesses.
here.** The survey behind this section was wrong three times before it was right, and the
way it was wrong is itself the argument for §16.7's wire-count requirement.

**Only three SDKs actually retried a read-only failure: Java, Rust, Go.** Two more had a
retry *surface* that no production path invoked, so they retried nothing while appearing to.
The remaining six had neither — only §9's refresh-then-retry-once, a different mechanism.

| SDK | Attempts | Base | Cap | Jitter | `Retry-After` | Actually retried? |
|---|---|---|---|---|---|---|
| Java | 3 | 200 ms | 5 s | full | floor | **yes** |
| Rust | 3 | library default | — | none | ignored | **yes** |
| Go | 3 | 100 ms | **none** | **none** | ignored | **yes** |
| TypeScript | 3 | 1000 ms | 8 s | partial | **replaced** the backoff | **no** — helper never called |
| C# | 3 | 200 ms | 5 s | yes | — | **no** — config never read |

Four things in that table, each a different way the same clause goes wrong.

*Go's row.* An uncapped, unjittered `backoff *= 2` is the shape this section most wants to
eliminate: without a cap the wait is bounded by nothing but the attempt count, and without
jitter every client retries in lockstep — the herd a backoff exists to prevent.

*TypeScript's `Retry-After`.* `retryAfterMs ?? backoff(n)` means the hint **replaces** the
computed backoff instead of flooring it, so a `Retry-After: 0` retries immediately. §16.1's
"floor, never a ceiling" was written on principle and then found to describe shipped code.

*The two "no" rows.* TypeScript's helper was exported and unit-tested; C#'s three settings
were defaulted, documented and asserted in tests. Both suites were green. Neither SDK
retried anything. **A tested surface nobody calls is worse than an absent one: the passing
tests are exactly what stop anyone from looking.** Hence §16.7 — an SDK claiming §16
conformance MUST assert the policy through its public `check_access` surface by counting
requests **on the wire**, not against a helper in isolation.

*C#'s configurability.* Its defaults matched this table, but `MaxRetryAttempts`,
`RetryBaseDelay` and `RetryMaxDelay` were publicly settable upward. §16.1 permits *lowering*
the cap or disabling retry, never raising either — a caller who can raise them turns one
client into the herd. (Fixed by clamping, in that SDK's D5 change.)

This section is the missing policy, so the two forward references resolve to one table
instead of eleven guesses.

### §16.1 The policy (normative — every value here is binding)

Expand Down Expand Up @@ -2201,6 +2235,7 @@ costs nothing to anyone who does not want it.
| `request_end` | After it completes, success or failure | the `request_start` fields, plus status code (or `None`), duration, outcome |
| `retry` | Before each §16 retry wait | operation name, attempt number, the delay about to be taken, the failure that triggered it |
| `refresh` | Around a §9 single-flight refresh | whether this caller performed the refresh or waited on another's |
| `config_clamped` | At client construction, once per clamped setting | the setting's name, the value the caller asked for, the value in force, and the §-reference for the limit |

`path template` means `/api/v1/authz/check`, not the URL with ids substituted in — a metric
label with a UUID in it is a cardinality bomb.
Expand All @@ -2225,6 +2260,23 @@ label with a UUID in it is a cardinality bomb.
them, plus one `retry` between consecutive pairs. A caller must be able to count real wire
calls from these events, so one pair per logical operation would be wrong.

6. **A clamped setting MUST be reported, not swallowed.** Wherever this contract requires an
SDK to clamp a caller-supplied value rather than reject it — §16.1's attempt cap, base
delay and delay cap; §17.1 rule 2's memo TTL — the SDK MUST emit one `config_clamped`
event per clamped setting at construction.

Clamping is the right behaviour: rejecting would break a caller whose configuration was
merely optimistic, and honoring would let one client become the herd §16 exists to
prevent. But *silently* clamping means an operator who set a 60-second memo TTL believes
they have one, and their staleness reasoning is wrong by a factor of twelve with nothing
anywhere to say so. The event is what makes the clamp discoverable at the only moment it
can be acted on.

`config_clamped` is exempt from §19.1's "no cost when uninstalled" framing only in the
sense that it fires at construction rather than per request; with no hook installed it is
still a null check and nothing more. It MUST NOT be emitted for a value that was already
within the limit — an event that fires when nothing happened trains its reader to ignore it.

### §19.3 Per-language naming map

| Canonical | Rust | TypeScript | Python | Java | Kotlin | C# | PHP | Go | Swift | C | C++ |
Expand Down Expand Up @@ -2530,6 +2582,6 @@ recorded here until one exists.

---

*Contract version: 1.8 — Phase 15 (sdk-foundation); §11 declarative authorization helpers added 2026-07; §6.1 mTLS client certificates and Kotlin/Swift/C/C++ SDK columns added 2026-07; §1.1 gRPC-only `get_user_info` operation added 2026-07; §12 OIDC/SSO relying-party helpers and the `OAuthProtocolError` taxonomy sub-type added 2026-07; §7 accessor rules, §9 rule 5, and the §12 cross-SDK clarifications from the eight-SDK conformance review added 2026-07; §9 rule 6 single-flight implementation invariants and the extended §9 test requirement added 2026-07; §8b AMQP transport, §10.2 gRPC revocation modes, §12.7 logout helpers, §14 device authorization grant and §15 token exchange added 2026-08; §14.3 rule 4 / §14.6 credential-adoption errata 2026-08 (contract 1.7); §16 retry policy, §17 decision memo, §18 deterministic shutdown and §19 telemetry hooks added 2026-08, with §11.2 rules 5–6 and §14.2 rule 6 amended to point at them (contract 1.8)*
*Contract version: 1.9 — Phase 15 (sdk-foundation); §11 declarative authorization helpers added 2026-07; §6.1 mTLS client certificates and Kotlin/Swift/C/C++ SDK columns added 2026-07; §1.1 gRPC-only `get_user_info` operation added 2026-07; §12 OIDC/SSO relying-party helpers and the `OAuthProtocolError` taxonomy sub-type added 2026-07; §7 accessor rules, §9 rule 5, and the §12 cross-SDK clarifications from the eight-SDK conformance review added 2026-07; §9 rule 6 single-flight implementation invariants and the extended §9 test requirement added 2026-07; §8b AMQP transport, §10.2 gRPC revocation modes, §12.7 logout helpers, §14 device authorization grant and §15 token exchange added 2026-08; §14.3 rule 4 / §14.6 credential-adoption errata 2026-08 (contract 1.7); §16 retry policy, §17 decision memo, §18 deterministic shutdown and §19 telemetry hooks added 2026-08, with §11.2 rules 5–6 and §14.2 rule 6 amended to point at them (contract 1.8); §16 preamble errata + §19 `config_clamped` event 2026-08 (contract 1.9) — the divergence table rewritten from wire-counting conformance tests rather than greps, and a clamped setting must now be reported through §19 rather than applied silently*
*Binding since: 2026-06-30*
*Reference: D-09, D-10 in `.planning/phases/15-sdk-foundation/15-CONTEXT.md`*
14 changes: 13 additions & 1 deletion src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,19 @@ reason: None,
oidc_refresh_inflight: crate::oidc::single_flight::OidcRefreshInflight::new(),
// §16.1: the policy is on unless the caller turns it off.
retry_enabled: self.retry_enabled.unwrap_or(true),
telemetry: crate::telemetry::Telemetry::new(self.telemetry),
telemetry: {
let telemetry = crate::telemetry::Telemetry::new(self.telemetry);
// §19.2 rule 6: a clamped setting is reported, not swallowed.
// Emitted here because construction is the only moment an
// operator can act on it.
let requested = self.decision_memo_ttl.unwrap_or(Duration::ZERO);
crate::memo::DecisionMemo::report_clamp(
requested,
requested.min(crate::memo::MAX_TTL),
&telemetry,
);
telemetry
},
// §17.1 rule 1: off unless the caller asked for it.
decision_memo: crate::memo::DecisionMemo::new(
self.decision_memo_ttl.unwrap_or(Duration::ZERO),
Expand Down
21 changes: 21 additions & 0 deletions src/memo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ use std::time::{Duration, Instant};
use uuid::Uuid;

use crate::rest::authz::AccessDecision;
use crate::telemetry::{Telemetry, TelemetryEvent};

/// The §17.1 rule 2 ceiling. A configured TTL above this is clamped, not
/// rejected: a caller who asked for 60 s wants caching, and silently giving
Expand Down Expand Up @@ -91,6 +92,26 @@ pub(crate) struct DecisionMemo {
}

impl DecisionMemo {
/// Report a clamped TTL through §19 (§19.2 rule 6).
///
/// Clamping is right; clamping *silently* is not. An operator who set a
/// 60-second TTL believes their staleness bound is 60 seconds — it is five,
/// and without this event nothing anywhere says so.
///
/// Nothing is emitted when the requested value was already inside the
/// limit, or when the memo is disabled.
pub(crate) fn report_clamp(requested: Duration, effective: Duration, telemetry: &Telemetry) {
if requested.is_zero() || requested == effective {
return;
}
telemetry.emit(TelemetryEvent::ConfigClamped {
setting: "decision_memo_ttl",
requested: format!("{requested:?}"),
effective: format!("{effective:?}"),
contract_reference: "§17.1 rule 2",
});
}

/// Build a memo with `ttl`, clamped to [`MAX_TTL`] (§17.1 rule 2).
pub(crate) fn new(ttl: Duration) -> Self {
Self {
Expand Down
26 changes: 26 additions & 0 deletions src/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,32 @@ pub enum TelemetryEvent {
/// How long the refresh (or the wait for one) took.
duration: Duration,
},
/// Emitted at construction, once per caller-supplied setting the SDK
/// clamped (§19.1, §19.2 rule 6).
///
/// Two places in the contract require clamping rather than rejecting:
/// §16.1's attempt cap, base delay and delay cap, and §17.1 rule 2's memo
/// TTL. Both clamps are right — rejecting would break a caller whose
/// configuration was merely optimistic, and honoring would let one client
/// become the herd §16 exists to prevent. Doing it *silently* is the part
/// that is wrong.
///
/// An operator who set a 60-second memo TTL believes they have one. They
/// have five seconds, and their staleness reasoning is off by a factor of
/// twelve with nothing anywhere to say so.
///
/// Not emitted for a value already within its limit: an event that fires
/// when nothing happened trains its reader to ignore it.
ConfigClamped {
/// The setting's name, e.g. `decision_memo_ttl`.
setting: &'static str,
/// The value the caller asked for, rendered.
requested: String,
/// The value actually in force, rendered.
effective: String,
/// The §-reference for the limit, e.g. `§17.1 rule 2`.
contract_reference: &'static str,
},
}

/// A caller-supplied telemetry sink (§19).
Expand Down
102 changes: 102 additions & 0 deletions tests/d5_config_clamped.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//! §19.2 rule 6 — a clamped setting is reported, not swallowed (contract 1.9).
//!
//! Clamping is right: rejecting would break a caller whose configuration was
//! merely optimistic, and honoring would let one client become the herd §16
//! exists to prevent. Doing it *silently* is the part that is wrong — an
//! operator who set a 60-second memo TTL believes they have one, and their
//! staleness reasoning is off by a factor of twelve with nothing to say so.

use std::sync::{Arc, Mutex};
use std::time::Duration;

use axiam_sdk::client::AxiamClient;
use axiam_sdk::telemetry::{TelemetryEvent, TelemetrySink};

/// Collects every event a client emits, so a test can assert on construction.
#[derive(Clone, Default)]
struct Collector(Arc<Mutex<Vec<TelemetryEvent>>>);

impl TelemetrySink for Collector {
fn emit(&self, event: &TelemetryEvent) {
self.0
.lock()
.expect("collector poisoned")
.push(event.clone());
}
}

impl Collector {
fn clamps(&self) -> Vec<(String, String, String)> {
self.0
.lock()
.expect("collector poisoned")
.iter()
.filter_map(|e| match e {
TelemetryEvent::ConfigClamped {
setting,
requested,
effective,
..
} => Some(((*setting).to_string(), requested.clone(), effective.clone())),
_ => None,
})
.collect()
}
}

fn client_with(ttl: Duration, collector: Collector) -> AxiamClient {
AxiamClient::builder()
.base_url("https://axiam-d5.test")
.expect("base url")
.tenant_slug("acme")
.org_slug("acme")
.decision_memo_ttl(ttl)
.telemetry_hook(collector)
.build()
.expect("client builds")
}

#[test]
fn clamping_the_memo_ttl_emits_config_clamped() {
let collector = Collector::default();
let _client = client_with(Duration::from_secs(60), collector.clone());

let clamps = collector.clamps();
assert_eq!(clamps.len(), 1, "expected exactly one clamp event");
assert_eq!(clamps[0].0, "decision_memo_ttl");
assert!(
clamps[0].2.contains('5'),
"effective value should render the 5s cap, got {}",
clamps[0].2
);
}

#[test]
fn a_value_already_within_its_limit_emits_nothing() {
// §19.2 rule 6: an event that fires when nothing happened trains its reader
// to ignore it.
let collector = Collector::default();
let _client = client_with(Duration::from_secs(2), collector.clone());

assert!(
collector.clamps().is_empty(),
"a TTL inside the limit was not clamped, so nothing may be reported"
);
}

#[test]
fn the_disabled_default_emits_nothing() {
// The overwhelmingly common case: no memo configured at all. Reporting a
// "clamp" of zero-to-zero would fire on every client ever built.
let collector = Collector::default();
let _client = AxiamClient::builder()
.base_url("https://axiam-d5.test")
.expect("base url")
.tenant_slug("acme")
.org_slug("acme")
.telemetry_hook(collector.clone())
.build()
.expect("client builds");

assert!(collector.clamps().is_empty());
}
Loading