From 7285ba3ff38966fc27cecae26758e0ecfe67ad83 Mon Sep 17 00:00:00 2001 From: Cameron Brooks Date: Wed, 24 Jun 2026 12:31:06 -0400 Subject: [PATCH] =?UTF-8?q?feat(conformance):=20gate=20min=5Ftimestamp=20a?= =?UTF-8?q?s=20a=20non-fatal=20freshness=20hint=20(=C2=A77.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the silent-divergence surface flagged by the pre-Wave-5 audit (G4): ezo returns CODE_DEADLINE_EXCEEDED on an unmet min_timestamp while sim/bread return CODE_OK + QUALITY_STALE, and no test covered it — so a shared SDK handler could converge either way undetected. Clarify semantics.md §7.3: min_timestamp constrains *quality*, not *success* — a provider MUST NOT turn an otherwise-readable signal into an error solely because the hint is unmet (the §7.3 SHOULDs are unchanged). Add the core gate test_min_timestamp_hint_is_not_fatal + the hermetic verifier self-test test_selftest_freshness_hint_validator, the checks.assert_freshness_hint_not_fatal helper, and a client read_signals(min_timestamp=...) param. No wire/proto change. Staged on main (no release yet): providers pin the harness, so this is inert in their CI until they bump. Validated locally: self-tests 35/35; against binaries sim PASS, bread SKIP (mock backend -> UNAVAILABLE, contract unobservable), ezo FAIL (CODE_DEADLINE_EXCEEDED) -> ezo's lenient fix + pin-bump lands in anolis-provider-ezo#70 (Phase C). Refs anolis-protocol#47, #49. --- CHANGELOG.md | 13 ++++++++ conformance/anolis_conformance/checks.py | 19 +++++++++++ conformance/anolis_conformance/client.py | 12 ++++++- .../anolis_conformance/test_adpp_core.py | 33 ++++++++++++++++++- .../anolis_conformance/test_selftest.py | 18 ++++++++++ docs/semantics.md | 8 +++++ 6 files changed, 101 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0862860..a7674aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to the Anolis Device Provider Protocol (ADPP) are documented ## [Unreleased] +### Added + +- **§7.3 freshness-hint gate** (core ADPP, non-waivable). `min_timestamp` is + clarified as a hint that constrains *quality*, not *success*: a provider MUST + NOT turn an otherwise-readable signal into an error (notably + `CODE_DEADLINE_EXCEEDED`) solely because the freshness hint is unmet — it + returns `CODE_OK` with best-available values (`test_min_timestamp_hint_is_not_fatal`, + validated hermetically by `test_selftest_freshness_hint_validator`). No + wire/proto change. Closes a silent-divergence surface ahead of the + provider-SDK extraction (anolis-protocol#47); a provider that errored on an + unmet hint becomes non-conformant when it bumps to the harness release + carrying this test. + ## [v1.5.0] — 2026-06-23 No wire/proto changes from v1.4.0 — this release is conformance-harness and diff --git a/conformance/anolis_conformance/checks.py b/conformance/anolis_conformance/checks.py index 8b19811..12d93ff 100644 --- a/conformance/anolis_conformance/checks.py +++ b/conformance/anolis_conformance/checks.py @@ -58,6 +58,25 @@ def assert_signalvalues_l2(read_response) -> None: raise ConformanceFailure(f"signal {sid!r}: quality must not be QUALITY_UNSPECIFIED") +def assert_freshness_hint_not_fatal(read_response, codes: SimpleNamespace) -> None: + """semantics.md §7.3: ``ReadSignalsRequest.min_timestamp`` is a best-effort + freshness **hint**, not a hard deadline. A provider that cannot satisfy it + MUST return the best available values (``CODE_OK``) and indicate staleness via + ``quality``/``Status.details`` — it MUST NOT, by itself, turn an otherwise- + readable signal into an error (notably ``CODE_DEADLINE_EXCEEDED``). A genuine + read failure (``CODE_UNAVAILABLE``) is unrelated and exempt; the caller first + establishes that a hint-free read of the same device succeeds.""" + code = read_response.status.code + if code in (codes.OK, codes.UNAVAILABLE): + return + name = next((n for n, v in vars(codes).items() if v == code), str(code)) + raise ConformanceFailure( + f"a read with min_timestamp set returned status CODE_{name} — §7.3 freshness is a " + "best-effort hint that constrains *quality*, not *success*: return best-available " + "values (CODE_OK) with QUALITY_STALE, not an error such as CODE_DEADLINE_EXCEEDED" + ) + + _SNAKE_CASE = re.compile(r"^[a-z][a-z0-9_]*$") diff --git a/conformance/anolis_conformance/client.py b/conformance/anolis_conformance/client.py index fbaeec3..88056d8 100644 --- a/conformance/anolis_conformance/client.py +++ b/conformance/anolis_conformance/client.py @@ -225,11 +225,21 @@ def describe_device(self, device_id: str) -> Any: req.describe_device.device_id = device_id return self.send_request(req) - def read_signals(self, device_id: str, signal_ids: Sequence[str] | None = None) -> Any: + def read_signals( + self, + device_id: str, + signal_ids: Sequence[str] | None = None, + *, + min_timestamp: tuple[int, int] | None = None, + ) -> Any: req = self.protocol.Request(request_id=self._request_id()) req.read_signals.device_id = device_id if signal_ids: req.read_signals.signal_ids.extend(signal_ids) + if min_timestamp is not None: + seconds, nanos = min_timestamp + req.read_signals.min_timestamp.seconds = seconds + req.read_signals.min_timestamp.nanos = nanos return self.send_request(req) def call( diff --git a/conformance/anolis_conformance/test_adpp_core.py b/conformance/anolis_conformance/test_adpp_core.py index d02b024..380b972 100644 --- a/conformance/anolis_conformance/test_adpp_core.py +++ b/conformance/anolis_conformance/test_adpp_core.py @@ -6,10 +6,12 @@ from __future__ import annotations +import time + import pytest from . import spec -from .checks import assert_status_present +from .checks import assert_freshness_hint_not_fatal, assert_status_present from .client import AdppClient @@ -132,6 +134,35 @@ def test_default_read_returns_declared_subset(ready_client: AdppClient, codes, s pytest.skip("no readable device declares signals") +def test_min_timestamp_hint_is_not_fatal(ready_client: AdppClient, codes, status_text) -> None: + # semantics.md §7.3: `ReadSignalsRequest.min_timestamp` is a best-effort + # freshness HINT. A provider that cannot satisfy it MUST return best-available + # values (CODE_OK) — indicating staleness via quality/Status.details — and MUST + # NOT turn an otherwise-readable signal into an error (notably + # CODE_DEADLINE_EXCEEDED). We use a min_timestamp an hour in the future, which + # no real reading can satisfy, so the hint is always unmet. + future = (int(time.time()) + 3600, 0) + checked = 0 + for d in ready_client.list_devices().list_devices.devices: + caps = ready_client.describe_device(d.device_id).describe_device.capabilities + if not caps.signals: + continue + # Establish that the device is readable at all without the hint; a mock + # backend may report UNAVAILABLE, in which case the contract is unobservable. + base = ready_client.read_signals(d.device_id) + if base.status.code == codes.UNAVAILABLE: + continue + assert base.status.code == codes.OK, ( + f"baseline (hint-free) read must be OK or UNAVAILABLE; got {status_text(base)}" + ) + resp = ready_client.read_signals(d.device_id, min_timestamp=future) + # The unmet freshness hint must not, by itself, fail the read. + assert_freshness_hint_not_fatal(resp, codes) + checked += 1 + if checked == 0: + pytest.skip("no readable device declares signals") + + def test_read_unknown_signal_consistent(ready_client: AdppClient, codes, status_text) -> None: # semantics.md 7.4: a provider MUST choose ONE consistent behavior for an # unknown signal id — either fail CODE_NOT_FOUND, OR return partial results diff --git a/conformance/anolis_conformance/test_selftest.py b/conformance/anolis_conformance/test_selftest.py index b156575..ba2c506 100644 --- a/conformance/anolis_conformance/test_selftest.py +++ b/conformance/anolis_conformance/test_selftest.py @@ -16,6 +16,7 @@ from .checks import ( ConformanceFailure, assert_controlled_malformed, + assert_freshness_hint_not_fatal, assert_function_ids_per_type_from_one, assert_signal_ids_snake_case, assert_signalvalues_l2, @@ -282,6 +283,23 @@ def test_selftest_signalvalues_l2_rejects_bad_timestamp(protocol, nanos, seconds assert_signalvalues_l2(resp) +# --- freshness-hint validator (the real §7.3 check the core suite runs) --- + + +def test_selftest_freshness_hint_validator(protocol, codes) -> None: + # §7.3: an unmet min_timestamp hint must not be fatal. OK / UNAVAILABLE pass; + # any error code (notably DEADLINE_EXCEEDED) is rejected. + for accepted in ("CODE_OK", "CODE_UNAVAILABLE"): + resp = protocol.Response() + resp.status.code = protocol.Status.Code.Value(accepted) + assert_freshness_hint_not_fatal(resp, codes) # must NOT raise + for rejected in ("CODE_DEADLINE_EXCEEDED", "CODE_INTERNAL", "CODE_FAILED_PRECONDITION"): + resp = protocol.Response() + resp.status.code = protocol.Status.Code.Value(rejected) + with pytest.raises(ConformanceFailure): + assert_freshness_hint_not_fatal(resp, codes) + + # --- capability-convention validators (executable profile §4) --- diff --git a/docs/semantics.md b/docs/semantics.md index f69d09d..98dd932 100644 --- a/docs/semantics.md +++ b/docs/semantics.md @@ -214,6 +214,14 @@ If `ReadSignalsRequest.min_timestamp` is provided: - Providers SHOULD attempt to satisfy the freshness requirement. - If not feasible, providers SHOULD return the best available values and indicate staleness via `quality` and/or `Status.details`. +- `min_timestamp` is a **hint that constrains quality, not success**: a provider + MUST NOT treat an unmet freshness hint as a hard deadline. An otherwise- + readable signal MUST NOT, on account of the hint alone, be turned into an error + response (notably `CODE_DEADLINE_EXCEEDED`); the read returns `CODE_OK` with the + best available values. (A genuine read failure may still return + `CODE_UNAVAILABLE` etc.) *(Clarification: previously unstated; a provider that + returned an error solely because the hint was unmet is non-conformant under + this rule.)* ### 7.4 Missing signals