From 849642c54e0681026635658ecf75c954f62f3326 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 14:31:53 -0700 Subject: [PATCH 01/34] prototype: evaluate bound successor observations Signed-off-by: altrudev <266135212+altrudev@users.noreply.github.com> --- src/agentrust_trace/successor_observation.py | 162 +++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 src/agentrust_trace/successor_observation.py diff --git a/src/agentrust_trace/successor_observation.py b/src/agentrust_trace/successor_observation.py new file mode 100644 index 00000000..e326a10e --- /dev/null +++ b/src/agentrust_trace/successor_observation.py @@ -0,0 +1,162 @@ +"""Experimental successor-observation evaluation for PIC/TRACE bridge review. + +This module is intentionally not wired into the v1 bridge schema. It isolates the +semantics proposed in #338 so reviewers can falsify the conclusion rules before any +wire-format decision is made. +""" + +from __future__ import annotations + +from collections.abc import Callable, Collection +from dataclasses import dataclass +from hmac import compare_digest +from typing import Any, Literal + +from agentrust_trace.intent_bridge import IntentBridgeError, digest_jcs +from agentrust_trace.sign import JCS_SAFE_INTEGER + +SuccessorStatus = Literal["established", "contradicted", "not-established"] + + +class SuccessorObservationError(ValueError): + """The successor artifact or its binding is malformed or inconsistent.""" + + +@dataclass(frozen=True) +class SuccessorOutcome: + """What the verifier may conclude from a bound successor observation.""" + + status: SuccessorStatus + reason: str + + +def _digest(value: Any, field: str) -> str: + if not isinstance(value, str) or not value.startswith("sha256:"): + raise SuccessorObservationError(f"{field} must be a sha256 digest") + tail = value[7:] + if len(tail) != 64 or any(c not in "0123456789abcdef" for c in tail): + raise SuccessorObservationError( + f"{field} must contain 64 lowercase hexadecimal characters" + ) + return value + + +def _not_established(reason: str) -> SuccessorOutcome: + return SuccessorOutcome("not-established", reason) + + +def evaluate_successor_observation( + after: dict[str, Any] | None, + *, + expected_observation_digest: str, + trusted_observers: Collection[str], + executor_id: str | None, + independence_required: bool, + now: int, + max_age_seconds: int, + predicate: Callable[[dict[str, Any]], bool | None], +) -> SuccessorOutcome: + """Evaluate a successor observation without conflating binding with closure. + + `expected_observation_digest` is supplied by the caller to represent whatever + binding mechanism the profile eventually chooses. This prototype deliberately + does not decide whether that digest belongs in the signed authorization, the + transcript, or a detached observation artifact. + + The predicate is application-defined and returns True when the requested + transition is established by the observation, False when trusted evidence + contradicts it, and None when the observation itself does not decide it. + """ + + expected = _digest(expected_observation_digest, "expected_observation_digest") + if not isinstance(independence_required, bool): + raise SuccessorObservationError("independence_required must be boolean") + for field, value in (("now", now), ("max_age_seconds", max_age_seconds)): + if ( + not isinstance(value, int) + or isinstance(value, bool) + or value < 0 + or value > JCS_SAFE_INTEGER + ): + raise SuccessorObservationError( + f"{field} must be a non-negative integer within the JCS safe-integer range" + ) + + if after is None: + return _not_established("successor observation is absent") + if not isinstance(after, dict): + raise SuccessorObservationError("successor observation envelope must be an object") + + required = {"observation", "observer", "observed_at"} + missing = required - set(after) + unknown = set(after) - required + if missing: + raise SuccessorObservationError( + f"successor observation is missing fields: {sorted(missing)}" + ) + if unknown: + raise SuccessorObservationError( + f"successor observation contains unknown fields: {sorted(unknown)}" + ) + + observation = after["observation"] + if not isinstance(observation, dict): + raise SuccessorObservationError("successor observation must be an object") + + observer = after["observer"] + if not isinstance(observer, str) or not observer: + raise SuccessorObservationError("successor observer must be a non-empty string") + + observed_at = after["observed_at"] + if ( + not isinstance(observed_at, int) + or isinstance(observed_at, bool) + or observed_at < 0 + or observed_at > JCS_SAFE_INTEGER + ): + raise SuccessorObservationError( + "successor observed_at must be a non-negative integer within the JCS safe-integer range" + ) + + try: + actual = digest_jcs(observation) + except IntentBridgeError as exc: + raise SuccessorObservationError( + f"successor observation has no RFC 8785 canonical form: {exc}" + ) from exc + if not compare_digest(expected, actual): + raise SuccessorObservationError( + "successor observation does not match the expected digest binding" + ) + + if observer not in trusted_observers: + return _not_established("successor observer is not trusted by verifier policy") + if observed_at > now: + return _not_established("successor observation is dated in the future") + if now - observed_at > max_age_seconds: + return _not_established("successor observation is stale") + + if independence_required: + if executor_id is None: + return _not_established( + "independent observation is required but executor identity is unavailable" + ) + if observer == executor_id: + return _not_established( + "independent observation is required but observer is the executor" + ) + + result = predicate(observation) + if result is True: + return SuccessorOutcome( + "established", + "trusted bound successor evidence satisfies the transition predicate", + ) + if result is False: + return SuccessorOutcome( + "contradicted", + "trusted bound successor evidence contradicts the transition predicate", + ) + if result is None: + return _not_established("transition predicate cannot decide from this observation") + raise SuccessorObservationError("transition predicate must return True, False, or None") From ff27c001be044a7e4b8acdeeeced7d285caa2c2d Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 14:31:56 -0700 Subject: [PATCH 02/34] test: falsify successor observation conclusion rules Signed-off-by: altrudev <266135212+altrudev@users.noreply.github.com> --- tests/test_successor_observation.py | 127 ++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/test_successor_observation.py diff --git a/tests/test_successor_observation.py b/tests/test_successor_observation.py new file mode 100644 index 00000000..7326b8fa --- /dev/null +++ b/tests/test_successor_observation.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import pytest + +from agentrust_trace.intent_bridge import digest_jcs +from agentrust_trace.successor_observation import ( + SuccessorObservationError, + evaluate_successor_observation, +) + + +def _after( + observation: dict | None = None, + *, + observer: str = "observer-1", + observed_at: int = 150, +) -> dict: + return { + "observation": observation or {"commit": "abc123", "reachable": True}, + "observer": observer, + "observed_at": observed_at, + } + + +def _evaluate( + after: dict | None, + *, + expected: str | None = None, + trusted: set[str] | None = None, + executor_id: str | None = "executor-1", + independence_required: bool = True, + now: int = 160, + max_age_seconds: int = 30, + predicate=lambda obs: obs.get("reachable") is True, +): + if expected is None: + expected = digest_jcs(after["observation"]) if after is not None else "sha256:" + "0" * 64 + return evaluate_successor_observation( + after, + expected_observation_digest=expected, + trusted_observers=trusted or {"observer-1"}, + executor_id=executor_id, + independence_required=independence_required, + now=now, + max_age_seconds=max_age_seconds, + predicate=predicate, + ) + + +def test_trusted_bound_successor_can_establish_transition() -> None: + outcome = _evaluate(_after()) + assert outcome.status == "established" + + +def test_trusted_bound_successor_can_contradict_transition() -> None: + after = _after({"commit": "abc123", "reachable": False}) + outcome = _evaluate(after, predicate=lambda obs: obs.get("reachable") is True) + assert outcome.status == "contradicted" + + +def test_missing_successor_is_not_established() -> None: + outcome = _evaluate(None) + assert outcome.status == "not-established" + assert "absent" in outcome.reason + + +def test_substituted_successor_fails_the_binding() -> None: + original = _after() + expected = digest_jcs(original["observation"]) + substituted = _after({"commit": "def456", "reachable": True}) + with pytest.raises(SuccessorObservationError, match="expected digest binding"): + _evaluate(substituted, expected=expected) + + +@pytest.mark.parametrize( + "observation", + [ + {"value": 2**60}, + {"value": float("nan")}, + ], +) +def test_uncanonicalizable_successor_is_malformed(observation: dict) -> None: + after = _after(observation) + with pytest.raises(SuccessorObservationError, match="canonical form"): + _evaluate(after, expected="sha256:" + "0" * 64) + + +def test_stale_successor_is_not_established() -> None: + outcome = _evaluate(_after(observed_at=100), now=160, max_age_seconds=30) + assert outcome.status == "not-established" + assert "stale" in outcome.reason + + +def test_untrusted_successor_is_not_established() -> None: + outcome = _evaluate(_after(observer="observer-2"), trusted={"observer-1"}) + assert outcome.status == "not-established" + assert "not trusted" in outcome.reason + + +def test_self_observation_does_not_close_when_independence_is_required() -> None: + after = _after(observer="executor-1") + outcome = _evaluate(after, trusted={"executor-1"}, independence_required=True) + assert outcome.status == "not-established" + assert "observer is the executor" in outcome.reason + + +def test_self_observation_can_close_when_policy_does_not_require_independence() -> None: + after = _after(observer="executor-1") + outcome = _evaluate(after, trusted={"executor-1"}, independence_required=False) + assert outcome.status == "established" + + +def test_missing_executor_identity_blocks_required_independence() -> None: + outcome = _evaluate(_after(), executor_id=None, independence_required=True) + assert outcome.status == "not-established" + assert "executor identity is unavailable" in outcome.reason + + +def test_indeterminate_predicate_is_not_established() -> None: + outcome = _evaluate(_after(), predicate=lambda obs: None) + assert outcome.status == "not-established" + assert "cannot decide" in outcome.reason + + +def test_predicate_must_return_three_state_value() -> None: + with pytest.raises(SuccessorObservationError, match="True, False, or None"): + _evaluate(_after(), predicate=lambda obs: "yes") From 2599be9dd731d819d3bb742bcc387bce957fe35d Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 14:31:58 -0700 Subject: [PATCH 03/34] docs: bound successor observation prototype Signed-off-by: altrudev <266135212+altrudev@users.noreply.github.com> --- .../successor-observation-prototype.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/integration/successor-observation-prototype.md diff --git a/docs/integration/successor-observation-prototype.md b/docs/integration/successor-observation-prototype.md new file mode 100644 index 00000000..67d23e57 --- /dev/null +++ b/docs/integration/successor-observation-prototype.md @@ -0,0 +1,77 @@ +# Successor-observation prototype for PIC/TRACE (#338) + +Status: experimental review artifact. This is not part of the PIC/TRACE bridge v1 wire format. + +## Purpose + +The current bridge requires `transcript.after` to be an object but does not bind its +identity or define what a verifier may conclude from it. Issue #338 proposes separating +two questions: + +1. **Integrity:** is this the exact successor observation that the profile bound? +2. **Sufficiency:** is that observation trusted, fresh, independent when required, and + decisive under the transition predicate? + +This prototype exists to make those conclusion rules executable before choosing a schema. + +## Prototype boundary + +`evaluate_successor_observation()` receives an `expected_observation_digest` from its +caller. That parameter deliberately stands in for the future binding mechanism. The +prototype does not decide whether the digest belongs in the signed authorization, +`transcript.after`, or a detached successor-observation artifact. + +The successor envelope is deliberately small: + +~~~json +{ + "observation": {"application": "defined"}, + "observer": "observer-identity", + "observed_at": 1750000000 +} +~~~ + +The verifier separately supplies: + +- the expected RFC 8785 / SHA-256 observation digest; +- its trusted observer set; +- the executor identity, if known; +- whether observer independence is required; +- freshness policy; +- an application-defined predicate. + +## Outcomes + +The evaluator returns exactly one of: + +- `established`: trusted, bound evidence satisfies the predicate; +- `contradicted`: trusted, bound evidence falsifies the predicate; +- `not-established`: the evidence is absent or insufficient to justify either result. + +Malformed artifacts and binding failures are errors rather than a fourth evidence result. + +The important rule is that a successful digest check is necessary for integrity but is +never sufficient for `established`. + +## Counterexample from #332 + +If an executor performs a Git push and then supplies the only observation saying the +repository reached the intended commit, the observation can be byte-perfect and still be +self-certified. With `independence_required=True`, the prototype therefore returns +`not-established` when `observer == executor_id`. + +The same evidence can establish a transition when policy does not require independence. +The prototype intentionally does not make independence universal. + +## Non-goals + +This prototype does not define: + +- the final bridge schema; +- a universal transition-predicate language; +- replay or one-shot authorization semantics; +- application-state storage in TRACE; +- a repository-wide status enum; +- proof of a real-world outcome merely from a bound transcript. + +It is intended to be falsified before any of those choices are made. From 50a9154527f39b99b7c4eeb7b434bd642365c4ad Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 14:32:46 -0700 Subject: [PATCH 04/34] prototype: bind complete successor envelope Signed-off-by: altrudev <266135212+altrudev@users.noreply.github.com> --- src/agentrust_trace/successor_observation.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/agentrust_trace/successor_observation.py b/src/agentrust_trace/successor_observation.py index e326a10e..ce829425 100644 --- a/src/agentrust_trace/successor_observation.py +++ b/src/agentrust_trace/successor_observation.py @@ -48,7 +48,7 @@ def _not_established(reason: str) -> SuccessorOutcome: def evaluate_successor_observation( after: dict[str, Any] | None, *, - expected_observation_digest: str, + expected_successor_digest: str, trusted_observers: Collection[str], executor_id: str | None, independence_required: bool, @@ -58,7 +58,7 @@ def evaluate_successor_observation( ) -> SuccessorOutcome: """Evaluate a successor observation without conflating binding with closure. - `expected_observation_digest` is supplied by the caller to represent whatever + `expected_successor_digest` is supplied by the caller to represent whatever binding mechanism the profile eventually chooses. This prototype deliberately does not decide whether that digest belongs in the signed authorization, the transcript, or a detached observation artifact. @@ -68,7 +68,7 @@ def evaluate_successor_observation( contradicts it, and None when the observation itself does not decide it. """ - expected = _digest(expected_observation_digest, "expected_observation_digest") + expected = _digest(expected_successor_digest, "expected_successor_digest") if not isinstance(independence_required, bool): raise SuccessorObservationError("independence_required must be boolean") for field, value in (("now", now), ("max_age_seconds", max_age_seconds)): @@ -119,14 +119,14 @@ def evaluate_successor_observation( ) try: - actual = digest_jcs(observation) + actual = digest_jcs(after) except IntentBridgeError as exc: raise SuccessorObservationError( - f"successor observation has no RFC 8785 canonical form: {exc}" + f"successor envelope has no RFC 8785 canonical form: {exc}" ) from exc if not compare_digest(expected, actual): raise SuccessorObservationError( - "successor observation does not match the expected digest binding" + "successor envelope does not match the expected digest binding" ) if observer not in trusted_observers: From b92b845d51ad41c3fe53c123c2682cb6bc2b81ec Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 14:32:48 -0700 Subject: [PATCH 05/34] test: prevent successor metadata relabelling Signed-off-by: altrudev <266135212+altrudev@users.noreply.github.com> --- tests/test_successor_observation.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/test_successor_observation.py b/tests/test_successor_observation.py index 7326b8fa..339bb31b 100644 --- a/tests/test_successor_observation.py +++ b/tests/test_successor_observation.py @@ -34,10 +34,10 @@ def _evaluate( predicate=lambda obs: obs.get("reachable") is True, ): if expected is None: - expected = digest_jcs(after["observation"]) if after is not None else "sha256:" + "0" * 64 + expected = digest_jcs(after) if after is not None else "sha256:" + "0" * 64 return evaluate_successor_observation( after, - expected_observation_digest=expected, + expected_successor_digest=expected, trusted_observers=trusted or {"observer-1"}, executor_id=executor_id, independence_required=independence_required, @@ -66,12 +66,28 @@ def test_missing_successor_is_not_established() -> None: def test_substituted_successor_fails_the_binding() -> None: original = _after() - expected = digest_jcs(original["observation"]) + expected = digest_jcs(original) substituted = _after({"commit": "def456", "reachable": True}) with pytest.raises(SuccessorObservationError, match="expected digest binding"): _evaluate(substituted, expected=expected) +def test_observer_metadata_is_inside_the_binding() -> None: + original = _after(observer="observer-1") + expected = digest_jcs(original) + relabelled = _after(observer="trusted-observer") + with pytest.raises(SuccessorObservationError, match="expected digest binding"): + _evaluate(relabelled, expected=expected, trusted={"trusted-observer"}) + + +def test_observation_time_is_inside_the_binding() -> None: + original = _after(observed_at=150) + expected = digest_jcs(original) + retimed = _after(observed_at=159) + with pytest.raises(SuccessorObservationError, match="expected digest binding"): + _evaluate(retimed, expected=expected) + + @pytest.mark.parametrize( "observation", [ From 4d1edf498a62e02244b77197f7e55120aff02e14 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 14:32:51 -0700 Subject: [PATCH 06/34] docs: bind successor provenance metadata Signed-off-by: altrudev <266135212+altrudev@users.noreply.github.com> --- docs/integration/successor-observation-prototype.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/integration/successor-observation-prototype.md b/docs/integration/successor-observation-prototype.md index 67d23e57..ca9ae02c 100644 --- a/docs/integration/successor-observation-prototype.md +++ b/docs/integration/successor-observation-prototype.md @@ -8,7 +8,7 @@ The current bridge requires `transcript.after` to be an object but does not bind identity or define what a verifier may conclude from it. Issue #338 proposes separating two questions: -1. **Integrity:** is this the exact successor observation that the profile bound? +1. **Integrity:** is this the exact successor envelope (observation, observer, and time) that the profile bound? 2. **Sufficiency:** is that observation trusted, fresh, independent when required, and decisive under the transition predicate? @@ -16,7 +16,7 @@ This prototype exists to make those conclusion rules executable before choosing ## Prototype boundary -`evaluate_successor_observation()` receives an `expected_observation_digest` from its +`evaluate_successor_observation()` receives an `expected_successor_digest` from its caller. That parameter deliberately stands in for the future binding mechanism. The prototype does not decide whether the digest belongs in the signed authorization, `transcript.after`, or a detached successor-observation artifact. @@ -33,7 +33,7 @@ The successor envelope is deliberately small: The verifier separately supplies: -- the expected RFC 8785 / SHA-256 observation digest; +- the expected RFC 8785 / SHA-256 digest of the complete successor envelope; - its trusted observer set; - the executor identity, if known; - whether observer independence is required; @@ -50,7 +50,7 @@ The evaluator returns exactly one of: Malformed artifacts and binding failures are errors rather than a fourth evidence result. -The important rule is that a successful digest check is necessary for integrity but is +The important rule is that a successful digest check binds observation content, observer identity, and observation time and is necessary for integrity but is never sufficient for `established`. ## Counterexample from #332 From 3585ca908f978e75516e4629af6bd2eca4eccf06 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 14:33:07 -0700 Subject: [PATCH 07/34] prototype: validate successor trust-policy inputs Signed-off-by: altrudev <266135212+altrudev@users.noreply.github.com> --- src/agentrust_trace/successor_observation.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/agentrust_trace/successor_observation.py b/src/agentrust_trace/successor_observation.py index ce829425..3647ba67 100644 --- a/src/agentrust_trace/successor_observation.py +++ b/src/agentrust_trace/successor_observation.py @@ -71,6 +71,17 @@ def evaluate_successor_observation( expected = _digest(expected_successor_digest, "expected_successor_digest") if not isinstance(independence_required, bool): raise SuccessorObservationError("independence_required must be boolean") + if ( + isinstance(trusted_observers, (str, bytes, bytearray, dict)) + or not all(isinstance(item, str) and item for item in trusted_observers) + ): + raise SuccessorObservationError( + "trusted_observers must be a collection of non-empty observer identity strings" + ) + if executor_id is not None and (not isinstance(executor_id, str) or not executor_id): + raise SuccessorObservationError("executor_id must be a non-empty string or None") + if not callable(predicate): + raise SuccessorObservationError("predicate must be callable") for field, value in (("now", now), ("max_age_seconds", max_age_seconds)): if ( not isinstance(value, int) From c366e571fcdb24b5e97d287c53c86ba2818663f1 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 14:33:10 -0700 Subject: [PATCH 08/34] test: reject ambiguous observer policy containers Signed-off-by: altrudev <266135212+altrudev@users.noreply.github.com> --- tests/test_successor_observation.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_successor_observation.py b/tests/test_successor_observation.py index 339bb31b..9293e57f 100644 --- a/tests/test_successor_observation.py +++ b/tests/test_successor_observation.py @@ -141,3 +141,28 @@ def test_indeterminate_predicate_is_not_established() -> None: def test_predicate_must_return_three_state_value() -> None: with pytest.raises(SuccessorObservationError, match="True, False, or None"): _evaluate(_after(), predicate=lambda obs: "yes") + + +@pytest.mark.parametrize( + "trusted", + ["observer-1", b"observer-1", {"observer-1": True}, {"", "observer-1"}], +) +def test_trusted_observers_must_be_an_actual_identity_collection(trusted) -> None: + after = _after() + with pytest.raises(SuccessorObservationError, match="trusted_observers"): + evaluate_successor_observation( + after, + expected_successor_digest=digest_jcs(after), + trusted_observers=trusted, + executor_id="executor-1", + independence_required=True, + now=160, + max_age_seconds=30, + predicate=lambda obs: True, + ) + + +def test_executor_identity_shape_is_validated() -> None: + after = _after() + with pytest.raises(SuccessorObservationError, match="executor_id"): + _evaluate(after, executor_id="") From 6a89044ed5a0121080cee8c4bb4332d65ce18e4d Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 14:33:25 -0700 Subject: [PATCH 09/34] prototype: fail closed on malformed trust policy Signed-off-by: altrudev <266135212+altrudev@users.noreply.github.com> --- src/agentrust_trace/successor_observation.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/agentrust_trace/successor_observation.py b/src/agentrust_trace/successor_observation.py index 3647ba67..a886a22d 100644 --- a/src/agentrust_trace/successor_observation.py +++ b/src/agentrust_trace/successor_observation.py @@ -72,7 +72,8 @@ def evaluate_successor_observation( if not isinstance(independence_required, bool): raise SuccessorObservationError("independence_required must be boolean") if ( - isinstance(trusted_observers, (str, bytes, bytearray, dict)) + not isinstance(trusted_observers, Collection) + or isinstance(trusted_observers, (str, bytes, bytearray, dict)) or not all(isinstance(item, str) and item for item in trusted_observers) ): raise SuccessorObservationError( From c731dbd21719d591f3bce05e62bca9ddce6fcd06 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 14:33:27 -0700 Subject: [PATCH 10/34] test: include null trust policy input Signed-off-by: altrudev <266135212+altrudev@users.noreply.github.com> --- tests/test_successor_observation.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_successor_observation.py b/tests/test_successor_observation.py index 9293e57f..184f863a 100644 --- a/tests/test_successor_observation.py +++ b/tests/test_successor_observation.py @@ -16,7 +16,11 @@ def _after( observed_at: int = 150, ) -> dict: return { - "observation": observation or {"commit": "abc123", "reachable": True}, + "observation": ( + observation + if observation is not None + else {"commit": "abc123", "reachable": True} + ), "observer": observer, "observed_at": observed_at, } @@ -145,7 +149,7 @@ def test_predicate_must_return_three_state_value() -> None: @pytest.mark.parametrize( "trusted", - ["observer-1", b"observer-1", {"observer-1": True}, {"", "observer-1"}], + [None, "observer-1", b"observer-1", {"observer-1": True}, {"", "observer-1"}], ) def test_trusted_observers_must_be_an_actual_identity_collection(trusted) -> None: after = _after() From 662e01f673870164bec96138c5eaa64813201d19 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 14:44:43 -0700 Subject: [PATCH 11/34] test: register successor evaluator in public-surface sweep Signed-off-by: altrudev <266135212+altrudev@users.noreply.github.com> --- ...blic_functions_raise_what_they_document.py | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/tests/test_public_functions_raise_what_they_document.py b/tests/test_public_functions_raise_what_they_document.py index 3fdbaea7..aa3de1aa 100644 --- a/tests/test_public_functions_raise_what_they_document.py +++ b/tests/test_public_functions_raise_what_they_document.py @@ -53,7 +53,7 @@ import agentrust_trace as at from agentrust_trace import (content_marking, generate_key, intent_bridge, key_to_jwk, - provenance, revocation, sign, validate) + provenance, revocation, sign, successor_observation, validate) #: Values a caller can supply where an object, a string, a key or bytes is expected. #: The last five are the ones that separate a strict canonicalizer from a permissive @@ -72,6 +72,7 @@ "provenance": ("ProvenanceError", "ToolCatalogMismatch"), "revocation": ("ValueError", "UnanchorableValue"), "sign": ("ValueError", "UnanchorableValue", "InvalidSignature"), + "successor_observation": ("SuccessorObservationError",), "validate": ("ValueError", "ValidationError"), "models": ("ValidationError",), "adapters": ("ValueError", "ValidationError"), @@ -109,6 +110,18 @@ "provenance.sign_record": lambda v: provenance.sign_record(v, _KEY), "provenance.tool_catalog_hash": provenance.tool_catalog_hash, "provenance.verify_record": lambda v: provenance.verify_record(v, _JWK), + "successor_observation.evaluate_successor_observation": lambda v: ( + successor_observation.evaluate_successor_observation( + v, + expected_successor_digest="sha256:" + "0" * 64, + trusted_observers={"observer-1"}, + executor_id="executor-1", + independence_required=True, + now=160, + max_age_seconds=30, + predicate=lambda obs: obs.get("reachable") is True, + ) + ), "revocation.bundle_digest": revocation.bundle_digest, "revocation.check_bundle": lambda v: revocation.check_bundle( v, trusted_key_identifiers=[], trusted_bundle_keys=[_JWK], now=1785000000, @@ -151,6 +164,12 @@ } _BRIDGE = intent_bridge.sign_bridge(_BRIDGE_AUTH, _KEY) _TRANSCRIPT = {"before": {"tool_call": dict(_TOOL_CALL)}, "after": {"status": "accepted"}} +_SUCCESSOR_AFTER = { + "observation": {"commit": "abc123", "reachable": True}, + "observer": "observer-1", + "observed_at": 150, +} +_SUCCESSOR_DIGEST = intent_bridge.digest_jcs(_SUCCESSOR_AFTER) _TOOLS = [{"name": "search", "description": "search", "input_schema": {"type": "object"}}] _ARTIFACT = {"package": "pkg:npm/%40acme/mcp-search@2.1.0", "digest": "sha256:" + "0" * 64} _PROVENANCE = provenance.build_record( @@ -189,6 +208,20 @@ ("bridge", "trusted_authorizer_jwk", "declaration", "pic_intent_digest", "pic_args_digest", "tool_call", "transcript", "now"), ), + "successor_observation.evaluate_successor_observation": ( + lambda: { + "after": _SUCCESSOR_AFTER, + "expected_successor_digest": _SUCCESSOR_DIGEST, + "trusted_observers": {"observer-1"}, + "executor_id": "executor-1", + "independence_required": True, + "now": 160, + "max_age_seconds": 30, + "predicate": lambda obs: obs.get("reachable") is True, + }, + ("expected_successor_digest", "trusted_observers", "executor_id", + "independence_required", "now", "max_age_seconds", "predicate"), + ), "provenance.build_record": ( lambda: {"kind": "publisher-asserted", "publisher": "did:web:acme.example", "tools": _TOOLS, "artifact": _ARTIFACT, "endpoint": None, "attestation": None, @@ -286,6 +319,7 @@ def test_every_public_function_is_either_swept_or_declared_unsweepable() -> None def _module_of(name: str) -> Any: return {"content_marking": content_marking, "intent_bridge": intent_bridge, "provenance": provenance, "revocation": revocation, "sign": sign, + "successor_observation": successor_observation, "validate": validate}[name.split(".")[0]] @@ -359,6 +393,8 @@ def test_no_keyword_argument_leaks_an_undocumented_exception(name: str, param: s "content_marking.verify_assertion": ("record_bytes", None, "ContentMarkingError"), "intent_bridge.sign_bridge": ("key", None, "IntentBridgeError"), "intent_bridge.verify_bridge": ("now", "a-string", "IntentBridgeError"), + "successor_observation.evaluate_successor_observation": + ("expected_successor_digest", None, "SuccessorObservationError"), "provenance.build_record": ("publisher", 123, "ProvenanceError"), "provenance.check_tool_catalog": ("tools", None, "ProvenanceError"), "provenance.sign_record": ("key", None, "ProvenanceError"), @@ -567,6 +603,8 @@ def test_no_public_function_raises_an_undocumented_exception(name: str) -> None: "provenance.sign_record": (None, "ProvenanceError"), "provenance.tool_catalog_hash": (None, "ProvenanceError"), "provenance.verify_record": (None, "ProvenanceError"), + "successor_observation.evaluate_successor_observation": + ("a-string", "SuccessorObservationError"), "revocation.bundle_digest": (None, "ValueError"), "sign.anchor_bytes": (b"bytes", "UnanchorableValue"), "sign.jwk_thumbprint": (None, "ValueError"), From 1bc5a63b5c0dbeb2ca9cffb3532317b3e21d8cc8 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 21:46:34 -0700 Subject: [PATCH 12/34] feat(intent-bridge): bind exact successor envelope --- src/agentrust_trace/intent_bridge.py | 47 ++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/agentrust_trace/intent_bridge.py b/src/agentrust_trace/intent_bridge.py index 2d91c91e..fd526a9c 100644 --- a/src/agentrust_trace/intent_bridge.py +++ b/src/agentrust_trace/intent_bridge.py @@ -105,6 +105,53 @@ def _nonempty_string(value: Any, field: str) -> str: return value +def _bind_successor_observation( + after: dict[str, Any], expected_successor_digest: str +) -> dict[str, Any]: + """Bind the exact successor envelope using the bridge identity relation. + + The digest covers observation content, observer identity, and observation time. + This establishes integrity only. Trust, freshness, independence, and predicate + sufficiency are deliberately evaluated separately. + """ + if not isinstance(after, dict): + raise AuthorizationMismatch("transcript.after must be a successor observation object") + required = {"observation", "observer", "observed_at"} + missing = required - set(after) + unknown = set(after) - required + if missing: + raise AuthorizationMismatch( + f"transcript.after is missing successor fields: {sorted(missing)}" + ) + if unknown: + raise AuthorizationMismatch( + f"transcript.after contains unknown successor fields: {sorted(unknown)}" + ) + if not isinstance(after["observation"], dict): + raise AuthorizationMismatch("transcript.after.observation must be an object") + _nonempty_string(after["observer"], "transcript.after.observer") + observed_at = after["observed_at"] + if ( + not isinstance(observed_at, int) + or isinstance(observed_at, bool) + or observed_at < 0 + ): + raise IntentBridgeError( + "transcript.after.observed_at must be a non-negative integer Unix timestamp" + ) + expected = _digest(expected_successor_digest, "expected_successor_digest") + try: + actual = digest_jcs(after) + except IntentBridgeError: + raise AuthorizationMismatch( + "transcript.after has no RFC 8785 canonical form" + ) from None + if not compare_digest(expected, actual): + raise AuthorizationMismatch( + "transcript.after does not match the expected successor digest" + ) + return after + def _decision(value: Any) -> str: """Return a valid authorization decision or refuse a malformed value.""" From e0eb0fda848cceedbbd99d7c56eeace85be0767c Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 21:46:47 -0700 Subject: [PATCH 13/34] refactor(successor): use intent-bridge binding --- src/agentrust_trace/successor_observation.py | 56 +++++--------------- 1 file changed, 12 insertions(+), 44 deletions(-) diff --git a/src/agentrust_trace/successor_observation.py b/src/agentrust_trace/successor_observation.py index a886a22d..99bc85a7 100644 --- a/src/agentrust_trace/successor_observation.py +++ b/src/agentrust_trace/successor_observation.py @@ -9,10 +9,13 @@ from collections.abc import Callable, Collection from dataclasses import dataclass -from hmac import compare_digest from typing import Any, Literal -from agentrust_trace.intent_bridge import IntentBridgeError, digest_jcs +from agentrust_trace.intent_bridge import ( + AuthorizationMismatch, + IntentBridgeError, + _bind_successor_observation, +) from agentrust_trace.sign import JCS_SAFE_INTEGER SuccessorStatus = Literal["established", "contradicted", "not-established"] @@ -96,50 +99,15 @@ def evaluate_successor_observation( if after is None: return _not_established("successor observation is absent") - if not isinstance(after, dict): - raise SuccessorObservationError("successor observation envelope must be an object") - - required = {"observation", "observer", "observed_at"} - missing = required - set(after) - unknown = set(after) - required - if missing: - raise SuccessorObservationError( - f"successor observation is missing fields: {sorted(missing)}" - ) - if unknown: - raise SuccessorObservationError( - f"successor observation contains unknown fields: {sorted(unknown)}" - ) - - observation = after["observation"] - if not isinstance(observation, dict): - raise SuccessorObservationError("successor observation must be an object") - - observer = after["observer"] - if not isinstance(observer, str) or not observer: - raise SuccessorObservationError("successor observer must be a non-empty string") - - observed_at = after["observed_at"] - if ( - not isinstance(observed_at, int) - or isinstance(observed_at, bool) - or observed_at < 0 - or observed_at > JCS_SAFE_INTEGER - ): - raise SuccessorObservationError( - "successor observed_at must be a non-negative integer within the JCS safe-integer range" - ) try: - actual = digest_jcs(after) - except IntentBridgeError as exc: - raise SuccessorObservationError( - f"successor envelope has no RFC 8785 canonical form: {exc}" - ) from exc - if not compare_digest(expected, actual): - raise SuccessorObservationError( - "successor envelope does not match the expected digest binding" - ) + bound = _bind_successor_observation(after, expected) + except (IntentBridgeError, AuthorizationMismatch) as exc: + raise SuccessorObservationError(str(exc)) from exc + + observation = bound["observation"] + observer = bound["observer"] + observed_at = bound["observed_at"] if observer not in trusted_observers: return _not_established("successor observer is not trusted by verifier policy") From ef4df1ef8395dd4074ee8c032f56d89424efaacd Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 21:46:57 -0700 Subject: [PATCH 14/34] docs(intent-bridge): define successor assurance boundary --- docs/integration/pic-trace-bridge-v1.md | 51 +++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/docs/integration/pic-trace-bridge-v1.md b/docs/integration/pic-trace-bridge-v1.md index 25e03906..29627839 100644 --- a/docs/integration/pic-trace-bridge-v1.md +++ b/docs/integration/pic-trace-bridge-v1.md @@ -38,6 +38,57 @@ required, and `before.tool_call` must equal the executed call. This binds the authorization to the call and its execution evidence without claiming that TRACE proves the real-world outcome of the call. +### Successor-observation binding + +A profile that elects to make a successor-state claim uses a successor envelope with +exactly three fields: + +~~~json +{ + "observation": {"application": "defined"}, + "observer": "observer-identity", + "observed_at": 1750000000 +} +~~~ + +The bridge identity relation is the SHA-256 digest of the RFC 8785 canonical bytes of +that complete envelope. The binding therefore covers the observation content, observer +identity, and observation timestamp together. Relabelling a genuine observation to a +different observer, retiming it, or altering its content changes the binding. + +A matching binding establishes **integrity**, not **sufficiency**. It does not by itself +establish that the requested transition occurred. A verifier evaluating a successor +claim separately applies its configured trust, freshness, and observation-source +policy and then an application- or profile-defined transition predicate. + +The successor-evaluation surface has three evidence outcomes: + +- `established`: trusted, bound successor evidence satisfies the transition predicate; +- `contradicted`: trusted, bound successor evidence contradicts the transition predicate; +- `not-established`: the available evidence is absent or insufficient to justify + either conclusion. + +Malformed successor artifacts and binding failures are refusals, not a fourth evidence +outcome. An absent `after` is `not-established`, not a refusal: the bridge cannot +distinguish a profile that elected a successor claim from one that did not merely from +absence, and absence MUST NOT become a positive conclusion. + +Observation independence is policy, not a universal rule. Where verifier policy +requires an observer independent of the executing principal, executor-supplied +successor evidence alone is insufficient and yields `not-established`. Where the +applicable policy permits deterministic local evidence, the same-principal observation +is not rejected merely because observer and executor are equal. + +A successful successor binding permits the verifier to conclude only that it is +evaluating the exact bound observation envelope and, after policy evaluation, that the +envelope's source/freshness/trust properties are acceptable. A transition-level +positive conclusion additionally requires the defined predicate over the relevant +predecessor, action/execution, and successor evidence. A bound `after` object alone +does not prove a real-world outcome. + +The surface-local three-state result is an instance of the evidence discipline tracked +in #279; it does not introduce a repository-wide status enum. + The reference implementation is `agentrust_trace.intent_bridge`; the versioned schema is `schema/pic-trace-bridge-v1.json`. From 863ccab0ff08c433ff3142daa694fd5f0c8c31e8 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 21:47:09 -0700 Subject: [PATCH 15/34] fix(intent-bridge): preserve successor binding domain --- src/agentrust_trace/intent_bridge.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/agentrust_trace/intent_bridge.py b/src/agentrust_trace/intent_bridge.py index fd526a9c..4c26de51 100644 --- a/src/agentrust_trace/intent_bridge.py +++ b/src/agentrust_trace/intent_bridge.py @@ -11,7 +11,12 @@ import rfc8785 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey -from agentrust_trace.sign import _b64url_decode, _canonical_bytes, _pubkey_from_jwk +from agentrust_trace.sign import ( + JCS_SAFE_INTEGER, + _b64url_decode, + _canonical_bytes, + _pubkey_from_jwk, +) BRIDGE_PROFILE = "tag:agentrust-io.com,2026:pic-trace-bridge-v1" PIC_PROFILE = "PIC-CJSON/1.0" @@ -135,9 +140,11 @@ def _bind_successor_observation( not isinstance(observed_at, int) or isinstance(observed_at, bool) or observed_at < 0 + or observed_at > JCS_SAFE_INTEGER ): raise IntentBridgeError( - "transcript.after.observed_at must be a non-negative integer Unix timestamp" + "transcript.after.observed_at must be a non-negative integer within " + "the JCS safe-integer range" ) expected = _digest(expected_successor_digest, "expected_successor_digest") try: @@ -148,7 +155,7 @@ def _bind_successor_observation( ) from None if not compare_digest(expected, actual): raise AuthorizationMismatch( - "transcript.after does not match the expected successor digest" + "transcript.after does not match the expected digest binding" ) return after From fc31f9ce2f70143e04808b56d8fa718e3b89b51e Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 21:47:31 -0700 Subject: [PATCH 16/34] docs(intent-bridge): retire successor prototype note --- .../successor-observation-prototype.md | 77 ------------------- 1 file changed, 77 deletions(-) delete mode 100644 docs/integration/successor-observation-prototype.md diff --git a/docs/integration/successor-observation-prototype.md b/docs/integration/successor-observation-prototype.md deleted file mode 100644 index ca9ae02c..00000000 --- a/docs/integration/successor-observation-prototype.md +++ /dev/null @@ -1,77 +0,0 @@ -# Successor-observation prototype for PIC/TRACE (#338) - -Status: experimental review artifact. This is not part of the PIC/TRACE bridge v1 wire format. - -## Purpose - -The current bridge requires `transcript.after` to be an object but does not bind its -identity or define what a verifier may conclude from it. Issue #338 proposes separating -two questions: - -1. **Integrity:** is this the exact successor envelope (observation, observer, and time) that the profile bound? -2. **Sufficiency:** is that observation trusted, fresh, independent when required, and - decisive under the transition predicate? - -This prototype exists to make those conclusion rules executable before choosing a schema. - -## Prototype boundary - -`evaluate_successor_observation()` receives an `expected_successor_digest` from its -caller. That parameter deliberately stands in for the future binding mechanism. The -prototype does not decide whether the digest belongs in the signed authorization, -`transcript.after`, or a detached successor-observation artifact. - -The successor envelope is deliberately small: - -~~~json -{ - "observation": {"application": "defined"}, - "observer": "observer-identity", - "observed_at": 1750000000 -} -~~~ - -The verifier separately supplies: - -- the expected RFC 8785 / SHA-256 digest of the complete successor envelope; -- its trusted observer set; -- the executor identity, if known; -- whether observer independence is required; -- freshness policy; -- an application-defined predicate. - -## Outcomes - -The evaluator returns exactly one of: - -- `established`: trusted, bound evidence satisfies the predicate; -- `contradicted`: trusted, bound evidence falsifies the predicate; -- `not-established`: the evidence is absent or insufficient to justify either result. - -Malformed artifacts and binding failures are errors rather than a fourth evidence result. - -The important rule is that a successful digest check binds observation content, observer identity, and observation time and is necessary for integrity but is -never sufficient for `established`. - -## Counterexample from #332 - -If an executor performs a Git push and then supplies the only observation saying the -repository reached the intended commit, the observation can be byte-perfect and still be -self-certified. With `independence_required=True`, the prototype therefore returns -`not-established` when `observer == executor_id`. - -The same evidence can establish a transition when policy does not require independence. -The prototype intentionally does not make independence universal. - -## Non-goals - -This prototype does not define: - -- the final bridge schema; -- a universal transition-predicate language; -- replay or one-shot authorization semantics; -- application-state storage in TRACE; -- a repository-wide status enum; -- proof of a real-world outcome merely from a bound transcript. - -It is intended to be falsified before any of those choices are made. From c7a657253cd91fc6e5eeaa055d82d1ebd2375542 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 21:56:34 -0700 Subject: [PATCH 17/34] docs(intent-bridge): keep informative wording non-normative --- docs/integration/pic-trace-bridge-v1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/integration/pic-trace-bridge-v1.md b/docs/integration/pic-trace-bridge-v1.md index 29627839..5c88c65c 100644 --- a/docs/integration/pic-trace-bridge-v1.md +++ b/docs/integration/pic-trace-bridge-v1.md @@ -71,7 +71,7 @@ The successor-evaluation surface has three evidence outcomes: Malformed successor artifacts and binding failures are refusals, not a fourth evidence outcome. An absent `after` is `not-established`, not a refusal: the bridge cannot distinguish a profile that elected a successor claim from one that did not merely from -absence, and absence MUST NOT become a positive conclusion. +absence, and absence must not become a positive conclusion. Observation independence is policy, not a universal rule. Where verifier policy requires an observer independent of the executing principal, executor-supplied From d22fe162fff93853e32cff115da3a95219e9ab53 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 21:56:45 -0700 Subject: [PATCH 18/34] docs(successor): describe integrated assurance surface From 117269e03ef7bcf4234942138ced59d499e12707 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Sat, 12 Sep 2026 21:57:03 -0700 Subject: [PATCH 19/34] docs(successor): describe integrated assurance surface --- src/agentrust_trace/successor_observation.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/agentrust_trace/successor_observation.py b/src/agentrust_trace/successor_observation.py index 99bc85a7..806a230c 100644 --- a/src/agentrust_trace/successor_observation.py +++ b/src/agentrust_trace/successor_observation.py @@ -1,8 +1,8 @@ -"""Experimental successor-observation evaluation for PIC/TRACE bridge review. +"""Evaluate bound PIC/TRACE successor observations without conflating integrity and closure. -This module is intentionally not wired into the v1 bridge schema. It isolates the -semantics proposed in #338 so reviewers can falsify the conclusion rules before any -wire-format decision is made. +The bridge-local binding establishes the exact successor envelope. This module applies +trust, freshness, independence policy, and an application-defined transition predicate +to produce the surface-local three-state evidence result defined by #338. """ from __future__ import annotations From a83cb3b3b389a2814bd691a28c4cd154b3dec3eb Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Mon, 14 Sep 2026 12:47:05 -0700 Subject: [PATCH 20/34] fix(intent-bridge): authenticate successor observation binding --- src/agentrust_trace/intent_bridge.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/agentrust_trace/intent_bridge.py b/src/agentrust_trace/intent_bridge.py index 4c26de51..5bdee914 100644 --- a/src/agentrust_trace/intent_bridge.py +++ b/src/agentrust_trace/intent_bridge.py @@ -212,7 +212,7 @@ def verify_bridge( fields = { "authorization_id", "decision", "authorizer", "authorizer_key_id", "authorized_at", "expires_at", "scope", "pic", "declaration_digest", - "tool_call_digest", "transcript_required", + "tool_call_digest", "successor_observation_digest", "transcript_required", } authorization = _object(root.get("authorization"), "authorization", fields) missing = fields - set(authorization) @@ -309,6 +309,10 @@ def verify_bridge( ) from None if not compare_digest(before_digest, tool_call_digest): raise AuthorizationMismatch("transcript.before.tool_call does not match execution") - if not isinstance(transcript.get("after"), dict): - raise AuthorizationMismatch("transcript.after must contain the execution result") + after = transcript.get("after") + expected_successor_digest = _digest( + authorization["successor_observation_digest"], + "authorization.successor_observation_digest", + ) + _bind_successor_observation(after, expected_successor_digest) return authorization From 8b300c66cb8bcc765309818fa261673c0a1ecf33 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Mon, 14 Sep 2026 12:47:20 -0700 Subject: [PATCH 21/34] schema(intent-bridge): require signed successor observation digest --- schema/pic-trace-bridge-v1.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/schema/pic-trace-bridge-v1.json b/schema/pic-trace-bridge-v1.json index 03193b67..ff35178f 100644 --- a/schema/pic-trace-bridge-v1.json +++ b/schema/pic-trace-bridge-v1.json @@ -9,7 +9,7 @@ "signature": {"type": "string", "pattern": "^[A-Za-z0-9_-]{86}$"}, "authorization": { "type": "object", "additionalProperties": false, - "required": ["authorization_id", "decision", "authorizer", "authorizer_key_id", "authorized_at", "expires_at", "scope", "pic", "declaration_digest", "tool_call_digest", "transcript_required"], + "required": ["authorization_id", "decision", "authorizer", "authorizer_key_id", "authorized_at", "expires_at", "scope", "pic", "declaration_digest", "tool_call_digest", "successor_observation_digest", "transcript_required"], "properties": { "authorization_id": {"type": "string", "minLength": 1}, "decision": {"enum": ["allow", "deny"]}, @@ -25,6 +25,7 @@ "profile": {"const": "PIC-CJSON/1.0"}, "intent_digest": {"$ref": "#/$defs/digest"}, "args_digest": {"$ref": "#/$defs/digest"} }}, "declaration_digest": {"$ref": "#/$defs/digest"}, "tool_call_digest": {"$ref": "#/$defs/digest"}, + "successor_observation_digest": {"$ref": "#/$defs/digest"}, "transcript_required": {"type": "boolean"} } } From 2bd1914f74dda168793d862e858d5bbc6f0a7eb6 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Mon, 14 Sep 2026 12:47:22 -0700 Subject: [PATCH 22/34] docs(intent-bridge): define authenticated successor binding --- docs/integration/pic-trace-bridge-v1.md | 28 ++++++++++++++++--------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/docs/integration/pic-trace-bridge-v1.md b/docs/integration/pic-trace-bridge-v1.md index 5c88c65c..b20e90cf 100644 --- a/docs/integration/pic-trace-bridge-v1.md +++ b/docs/integration/pic-trace-bridge-v1.md @@ -34,14 +34,18 @@ objects. The verifier checks that the executed tool is in `scope.tools`, the declaration impact is in `scope.impacts`, and both bridge-specific digests match. If `transcript_required` is true, a complete `before` and `after` transcript is -required, and `before.tool_call` must equal the executed call. This binds the -authorization to the call and its execution evidence without claiming that -TRACE proves the real-world outcome of the call. +required. `before.tool_call` must equal the executed call, and `after` must be +the successor-observation envelope whose RFC 8785 / SHA-256 digest equals the +signed `authorization.successor_observation_digest`. Because that digest is +inside the signed authorization, a caller cannot substitute both a new +observation and a matching expected digest. This binds the authorization to the +exact call and exact successor envelope without claiming that TRACE proves the +real-world outcome of the call. ### Successor-observation binding -A profile that elects to make a successor-state claim uses a successor envelope with -exactly three fields: +When `transcript_required` is true, `transcript.after` is the successor envelope and +has exactly three fields: ~~~json { @@ -52,8 +56,10 @@ exactly three fields: ~~~ The bridge identity relation is the SHA-256 digest of the RFC 8785 canonical bytes of -that complete envelope. The binding therefore covers the observation content, observer -identity, and observation timestamp together. Relabelling a genuine observation to a +that complete envelope. The expected digest is carried in the signed +`authorization.successor_observation_digest`; it is not supplied independently by the +caller. The binding therefore covers the observation content, observer identity, and +observation timestamp together. Relabelling a genuine observation to a different observer, retiming it, or altering its content changes the binding. A matching binding establishes **integrity**, not **sufficiency**. It does not by itself @@ -69,9 +75,11 @@ The successor-evaluation surface has three evidence outcomes: either conclusion. Malformed successor artifacts and binding failures are refusals, not a fourth evidence -outcome. An absent `after` is `not-established`, not a refusal: the bridge cannot -distinguish a profile that elected a successor claim from one that did not merely from -absence, and absence must not become a positive conclusion. +outcome. At the bridge layer, an absent `after` is a refusal when +`transcript_required` is true because the signed authorization explicitly requires the +successor binding. At the separate successor-evaluation surface, where an observation may +be absent before bridge verification is attempted, absence remains +`not-established` and never becomes a positive conclusion. Observation independence is policy, not a universal rule. Where verifier policy requires an observer independent of the executing principal, executor-supplied From f858ed6e66a4bda850f09a2f037cb5591243a33c Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Mon, 14 Sep 2026 12:47:48 -0700 Subject: [PATCH 23/34] test(intent-bridge): cover authenticated successor binding --- tests/test_intent_bridge.py | 41 +++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/tests/test_intent_bridge.py b/tests/test_intent_bridge.py index 3f6b62f8..c961c562 100644 --- a/tests/test_intent_bridge.py +++ b/tests/test_intent_bridge.py @@ -24,6 +24,11 @@ def _fixture( key = Ed25519PrivateKey.generate() declaration = declaration or {"impact": "external-side-effect", "purpose": "send invoice"} tool_call = tool_call or {"name": "send_invoice", "arguments": {"invoice_id": "INV-7"}} + after = { + "observation": {"status": "accepted"}, + "observer": "observer-1", + "observed_at": 150, + } authorization = { "authorization_id": "auth-7", "decision": "allow", @@ -39,10 +44,11 @@ def _fixture( }, "declaration_digest": digest_jcs(declaration), "tool_call_digest": digest_jcs(tool_call), + "successor_observation_digest": digest_jcs(after), "transcript_required": True, } bridge = sign_bridge(authorization, key) - transcript = {"before": {"tool_call": tool_call}, "after": {"status": "accepted"}} + transcript = {"before": {"tool_call": tool_call}, "after": after} return ( bridge, key, declaration, authorization["pic"]["intent_digest"], authorization["pic"]["args_digest"], tool_call, transcript, @@ -59,6 +65,32 @@ def test_verify_bridge_accepts_authorized_bound_execution() -> None: assert result["authorization_id"] == "auth-7" +def test_successor_observation_is_bound_to_signed_authorization() -> None: + bridge, key, declaration, intent, args, tool_call, transcript = _fixture() + substituted = copy.deepcopy(transcript) + substituted["after"]["observation"]["status"] = "different" + with pytest.raises(AuthorizationMismatch, match="expected digest binding"): + verify_bridge( + bridge, {**key_to_jwk(key), "kid": "key-7"}, declaration=declaration, + pic_intent_digest=intent, pic_args_digest=args, tool_call=tool_call, + transcript=substituted, now=150, + ) + + +def test_caller_cannot_substitute_successor_and_matching_digest_without_resigning() -> None: + bridge, key, declaration, intent, args, tool_call, transcript = _fixture() + tampered = copy.deepcopy(bridge) + substituted = copy.deepcopy(transcript) + substituted["after"]["observation"]["status"] = "different" + tampered["authorization"]["successor_observation_digest"] = digest_jcs(substituted["after"]) + with pytest.raises(IntentBridgeError, match="authorization signature is invalid"): + verify_bridge( + tampered, {**key_to_jwk(key), "kid": "key-7"}, declaration=declaration, + pic_intent_digest=intent, pic_args_digest=args, tool_call=tool_call, + transcript=substituted, now=150, + ) + + @pytest.mark.parametrize("field", ["authorization", "signature"]) def test_tampering_is_rejected(field: str) -> None: bridge, key, declaration, intent, args, tool_call, transcript = _fixture() @@ -325,7 +357,7 @@ def test_transcript_call_is_compared_over_canonical_bytes_not_python_equality( verify_bridge( bridge, {**key_to_jwk(key), "kid": "key-7"}, declaration=declaration, pic_intent_digest=intent, pic_args_digest=args, tool_call=tool_call, - transcript={"before": {"tool_call": substituted}, "after": {"status": "accepted"}}, + transcript={"before": {"tool_call": substituted}, "after": _fixture(tool_call=call)[6]["after"]}, now=150, ) @@ -347,11 +379,12 @@ def test_transcript_call_that_is_not_an_object_stays_an_authorization_mismatch( bad: object, ) -> None: """Comparing digests must not turn a malformed transcript into a different class.""" - bridge, key, declaration, intent, args, tool_call, _ = _fixture() + bridge, key, declaration, intent, args, tool_call, transcript = _fixture() + transcript_after = transcript["after"] with pytest.raises(AuthorizationMismatch, match="transcript.before.tool_call"): verify_bridge( bridge, {**key_to_jwk(key), "kid": "key-7"}, declaration=declaration, pic_intent_digest=intent, pic_args_digest=args, tool_call=tool_call, - transcript={"before": {"tool_call": bad}, "after": {"status": "accepted"}}, + transcript={"before": {"tool_call": bad}, "after": transcript_after}, now=150, ) From 6c947fcc5f40930da364f491850f05ce5c69e3a2 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Mon, 14 Sep 2026 12:48:10 -0700 Subject: [PATCH 24/34] test(intent-bridge): simplify successor regression fixture --- tests/test_intent_bridge.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_intent_bridge.py b/tests/test_intent_bridge.py index c961c562..d4400860 100644 --- a/tests/test_intent_bridge.py +++ b/tests/test_intent_bridge.py @@ -349,7 +349,7 @@ def test_transcript_call_is_compared_over_canonical_bytes_not_python_equality( ) -> None: """#317: `True == 1` in Python, so `!=` accepted a JSON-distinct transcript call.""" call = {"name": "send_invoice", "arguments": executed} - bridge, key, declaration, intent, args, tool_call, _ = _fixture(tool_call=call) + bridge, key, declaration, intent, args, tool_call, transcript = _fixture(tool_call=call) substituted = {"name": "send_invoice", "arguments": transcribed} assert substituted == tool_call, "the substitution must be Python-equal to be a regression" assert digest_jcs(substituted) != digest_jcs(tool_call) @@ -357,7 +357,7 @@ def test_transcript_call_is_compared_over_canonical_bytes_not_python_equality( verify_bridge( bridge, {**key_to_jwk(key), "kid": "key-7"}, declaration=declaration, pic_intent_digest=intent, pic_args_digest=args, tool_call=tool_call, - transcript={"before": {"tool_call": substituted}, "after": _fixture(tool_call=call)[6]["after"]}, + transcript={"before": {"tool_call": substituted}, "after": transcript["after"]}, now=150, ) From 06f56cb2746feb80f0895643e9b5c585ed1f9d8d Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Mon, 14 Sep 2026 12:48:12 -0700 Subject: [PATCH 25/34] docs(successor): remove prototype ambiguity from binding contract --- src/agentrust_trace/successor_observation.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/agentrust_trace/successor_observation.py b/src/agentrust_trace/successor_observation.py index 806a230c..6be0a36f 100644 --- a/src/agentrust_trace/successor_observation.py +++ b/src/agentrust_trace/successor_observation.py @@ -61,10 +61,9 @@ def evaluate_successor_observation( ) -> SuccessorOutcome: """Evaluate a successor observation without conflating binding with closure. - `expected_successor_digest` is supplied by the caller to represent whatever - binding mechanism the profile eventually chooses. This prototype deliberately - does not decide whether that digest belongs in the signed authorization, the - transcript, or a detached observation artifact. + `expected_successor_digest` is the authenticated binding value established by + the bridge profile. In the PIC/TRACE bridge it comes from the signed + `authorization.successor_observation_digest`, not from the observed envelope. The predicate is application-defined and returns True when the requested transition is established by the observation, False when trusted evidence From 73d0fd842658a3039126e9a1aa3713d07b11b18c Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Mon, 14 Sep 2026 12:48:15 -0700 Subject: [PATCH 26/34] test(successor): add freshness and empty-trust edge cases --- tests/test_successor_observation.py | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_successor_observation.py b/tests/test_successor_observation.py index 184f863a..0a1cfb3f 100644 --- a/tests/test_successor_observation.py +++ b/tests/test_successor_observation.py @@ -111,6 +111,38 @@ def test_stale_successor_is_not_established() -> None: assert "stale" in outcome.reason + +def test_future_successor_is_not_established() -> None: + outcome = _evaluate(_after(observed_at=161), now=160, max_age_seconds=30) + assert outcome.status == "not-established" + assert "future" in outcome.reason + + +def test_zero_age_accepts_only_same_instant_observation() -> None: + same = _evaluate(_after(observed_at=160), now=160, max_age_seconds=0) + assert same.status == "established" + + older = _evaluate(_after(observed_at=159), now=160, max_age_seconds=0) + assert older.status == "not-established" + assert "stale" in older.reason + + +def test_empty_trusted_observer_set_is_valid_but_establishes_nothing() -> None: + after = _after() + outcome = evaluate_successor_observation( + after, + expected_successor_digest=digest_jcs(after), + trusted_observers=set(), + executor_id="executor-1", + independence_required=True, + now=160, + max_age_seconds=30, + predicate=lambda obs: True, + ) + assert outcome.status == "not-established" + assert "not trusted" in outcome.reason + + def test_untrusted_successor_is_not_established() -> None: outcome = _evaluate(_after(observer="observer-2"), trusted={"observer-1"}) assert outcome.status == "not-established" From 333e7df9d2cd29b4d7c81b33bd10ade78aa1769f Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Mon, 14 Sep 2026 12:48:35 -0700 Subject: [PATCH 27/34] test(public-surface): update bridge fixture for successor binding --- ...t_public_functions_raise_what_they_document.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/test_public_functions_raise_what_they_document.py b/tests/test_public_functions_raise_what_they_document.py index aa3de1aa..fd94f278 100644 --- a/tests/test_public_functions_raise_what_they_document.py +++ b/tests/test_public_functions_raise_what_they_document.py @@ -152,6 +152,12 @@ _ASSERTION = content_marking.build_assertion(_RECORD_JSON, url="https://r.example/r.json") _DECLARATION = {"impact": "external-side-effect", "purpose": "send invoice"} _TOOL_CALL = {"name": "send_invoice", "arguments": {"approved": 1}} +_SUCCESSOR_AFTER = { + "observation": {"commit": "abc123", "reachable": True}, + "observer": "observer-1", + "observed_at": 150, +} +_SUCCESSOR_DIGEST = intent_bridge.digest_jcs(_SUCCESSOR_AFTER) _BRIDGE_AUTH = { "authorization_id": "auth-1", "decision": "allow", "authorizer": "finance-policy", "authorizer_key_id": "key-1", "authorized_at": 100, "expires_at": 200, @@ -160,16 +166,11 @@ "args_digest": "sha256:" + "2" * 64}, "declaration_digest": intent_bridge.digest_jcs(_DECLARATION), "tool_call_digest": intent_bridge.digest_jcs(_TOOL_CALL), + "successor_observation_digest": _SUCCESSOR_DIGEST, "transcript_required": True, } _BRIDGE = intent_bridge.sign_bridge(_BRIDGE_AUTH, _KEY) -_TRANSCRIPT = {"before": {"tool_call": dict(_TOOL_CALL)}, "after": {"status": "accepted"}} -_SUCCESSOR_AFTER = { - "observation": {"commit": "abc123", "reachable": True}, - "observer": "observer-1", - "observed_at": 150, -} -_SUCCESSOR_DIGEST = intent_bridge.digest_jcs(_SUCCESSOR_AFTER) +_TRANSCRIPT = {"before": {"tool_call": dict(_TOOL_CALL)}, "after": _SUCCESSOR_AFTER} _TOOLS = [{"name": "search", "description": "search", "input_schema": {"type": "object"}}] _ARTIFACT = {"package": "pkg:npm/%40acme/mcp-search@2.1.0", "digest": "sha256:" + "0" * 64} _PROVENANCE = provenance.build_record( From b772d42f6fdb574e7a533c754182234bea7fdc05 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Mon, 14 Sep 2026 12:49:20 -0700 Subject: [PATCH 28/34] fix(intent-bridge): scope successor digest to transcript-required authorizations --- src/agentrust_trace/intent_bridge.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/agentrust_trace/intent_bridge.py b/src/agentrust_trace/intent_bridge.py index 5bdee914..77c9f296 100644 --- a/src/agentrust_trace/intent_bridge.py +++ b/src/agentrust_trace/intent_bridge.py @@ -214,8 +214,9 @@ def verify_bridge( "authorized_at", "expires_at", "scope", "pic", "declaration_digest", "tool_call_digest", "successor_observation_digest", "transcript_required", } + required_fields = fields - {"successor_observation_digest"} authorization = _object(root.get("authorization"), "authorization", fields) - missing = fields - set(authorization) + missing = required_fields - set(authorization) if missing: raise IntentBridgeError(f"authorization is missing fields: {sorted(missing)}") for field in ("authorization_id", "authorizer", "authorizer_key_id"): @@ -290,6 +291,15 @@ def verify_bridge( if not isinstance(authorization["transcript_required"], bool): raise IntentBridgeError("transcript_required must be boolean") + successor_digest_present = "successor_observation_digest" in authorization + if authorization["transcript_required"] and not successor_digest_present: + raise IntentBridgeError( + "authorization.successor_observation_digest is required when transcript_required is true" + ) + if not authorization["transcript_required"] and successor_digest_present: + raise IntentBridgeError( + "authorization.successor_observation_digest must be absent when transcript_required is false" + ) if authorization["transcript_required"]: if not isinstance(transcript, dict) or set(transcript) != {"before", "after"}: raise AuthorizationMismatch("a full before/after transcript is required") From 8b43a9410de96a97813e2fd0382da84dad8fb981 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Mon, 14 Sep 2026 12:49:22 -0700 Subject: [PATCH 29/34] schema(intent-bridge): require successor digest iff transcript is required --- schema/pic-trace-bridge-v1.json | 174 +++++++++++++++++++++++++++----- 1 file changed, 150 insertions(+), 24 deletions(-) diff --git a/schema/pic-trace-bridge-v1.json b/schema/pic-trace-bridge-v1.json index ff35178f..b5f9004c 100644 --- a/schema/pic-trace-bridge-v1.json +++ b/schema/pic-trace-bridge-v1.json @@ -2,33 +2,159 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://trace.agentrust-io.com/schema/pic-trace-bridge-v1.json", "title": "PIC/TRACE Bridge Authorization v1", - "type": "object", "additionalProperties": false, - "required": ["profile", "authorization", "signature"], + "type": "object", + "additionalProperties": false, + "required": [ + "profile", + "authorization", + "signature" + ], "properties": { - "profile": {"const": "tag:agentrust-io.com,2026:pic-trace-bridge-v1"}, - "signature": {"type": "string", "pattern": "^[A-Za-z0-9_-]{86}$"}, + "profile": { + "const": "tag:agentrust-io.com,2026:pic-trace-bridge-v1" + }, + "signature": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{86}$" + }, "authorization": { - "type": "object", "additionalProperties": false, - "required": ["authorization_id", "decision", "authorizer", "authorizer_key_id", "authorized_at", "expires_at", "scope", "pic", "declaration_digest", "tool_call_digest", "successor_observation_digest", "transcript_required"], + "type": "object", + "additionalProperties": false, + "required": [ + "authorization_id", + "decision", + "authorizer", + "authorizer_key_id", + "authorized_at", + "expires_at", + "scope", + "pic", + "declaration_digest", + "tool_call_digest", + "transcript_required" + ], "properties": { - "authorization_id": {"type": "string", "minLength": 1}, - "decision": {"enum": ["allow", "deny"]}, - "authorizer": {"type": "string", "minLength": 1}, - "authorizer_key_id": {"type": "string", "minLength": 1}, - "authorized_at": {"type": "integer", "minimum": 0, "maximum": 9007199254740991}, - "expires_at": {"type": "integer", "minimum": 0, "maximum": 9007199254740991}, - "scope": {"type": "object", "additionalProperties": false, "required": ["tools", "impacts"], "properties": { - "tools": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, - "impacts": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}} - }}, - "pic": {"type": "object", "additionalProperties": false, "required": ["profile", "intent_digest", "args_digest"], "properties": { - "profile": {"const": "PIC-CJSON/1.0"}, "intent_digest": {"$ref": "#/$defs/digest"}, "args_digest": {"$ref": "#/$defs/digest"} - }}, - "declaration_digest": {"$ref": "#/$defs/digest"}, "tool_call_digest": {"$ref": "#/$defs/digest"}, - "successor_observation_digest": {"$ref": "#/$defs/digest"}, - "transcript_required": {"type": "boolean"} - } + "authorization_id": { + "type": "string", + "minLength": 1 + }, + "decision": { + "enum": [ + "allow", + "deny" + ] + }, + "authorizer": { + "type": "string", + "minLength": 1 + }, + "authorizer_key_id": { + "type": "string", + "minLength": 1 + }, + "authorized_at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "expires_at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "scope": { + "type": "object", + "additionalProperties": false, + "required": [ + "tools", + "impacts" + ], + "properties": { + "tools": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "impacts": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + } + } + }, + "pic": { + "type": "object", + "additionalProperties": false, + "required": [ + "profile", + "intent_digest", + "args_digest" + ], + "properties": { + "profile": { + "const": "PIC-CJSON/1.0" + }, + "intent_digest": { + "$ref": "#/$defs/digest" + }, + "args_digest": { + "$ref": "#/$defs/digest" + } + } + }, + "declaration_digest": { + "$ref": "#/$defs/digest" + }, + "tool_call_digest": { + "$ref": "#/$defs/digest" + }, + "successor_observation_digest": { + "$ref": "#/$defs/digest" + }, + "transcript_required": { + "type": "boolean" + } + }, + "allOf": [ + { + "if": { + "properties": { + "transcript_required": { + "const": true + } + }, + "required": [ + "transcript_required" + ] + }, + "then": { + "required": [ + "successor_observation_digest" + ] + }, + "else": { + "not": { + "required": [ + "successor_observation_digest" + ] + } + } + } + ] } }, - "$defs": {"digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}} + "$defs": { + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + } } From d9ac670fcfe6b7d847c6f50e08e60d341e129bf6 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Mon, 14 Sep 2026 12:49:56 -0700 Subject: [PATCH 30/34] test(schema): update bridge artifact for successor digest --- tests/test_safe_integer_range.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_safe_integer_range.py b/tests/test_safe_integer_range.py index aeb69907..074bb32f 100644 --- a/tests/test_safe_integer_range.py +++ b/tests/test_safe_integer_range.py @@ -436,6 +436,7 @@ def _positions(node: Any, prefix: tuple[Any, ...] = ()) -> list[tuple[tuple[Any, }, "declaration_digest": "sha256:" + "c" * 64, "tool_call_digest": "sha256:" + "d" * 64, + "successor_observation_digest": "sha256:" + "e" * 64, "transcript_required": True, }, "signature": "x" * 86, From 30c05c2711ff713c1a8e510beb05b726e60243af Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Mon, 14 Sep 2026 12:50:00 -0700 Subject: [PATCH 31/34] test(intent-bridge): enforce successor digest iff transcript required --- tests/test_intent_bridge.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_intent_bridge.py b/tests/test_intent_bridge.py index d4400860..621eb9ce 100644 --- a/tests/test_intent_bridge.py +++ b/tests/test_intent_bridge.py @@ -234,6 +234,41 @@ def test_a_declaration_with_no_impact_at_all_is_refused() -> None: _verify(*_fixture(declaration={"purpose": "send invoice"})) +def test_successor_digest_is_required_iff_transcript_is_required() -> None: + bridge, key, declaration, intent, args, tool_call, transcript = _fixture() + + missing = copy.deepcopy(bridge["authorization"]) + del missing["successor_observation_digest"] + missing_bridge = sign_bridge(missing, key) + with pytest.raises(IntentBridgeError, match="successor_observation_digest is required"): + verify_bridge( + missing_bridge, {**key_to_jwk(key), "kid": "key-7"}, + declaration=declaration, pic_intent_digest=intent, pic_args_digest=args, + tool_call=tool_call, transcript=transcript, now=150, + ) + + no_transcript = copy.deepcopy(bridge["authorization"]) + no_transcript["transcript_required"] = False + del no_transcript["successor_observation_digest"] + no_transcript_bridge = sign_bridge(no_transcript, key) + result = verify_bridge( + no_transcript_bridge, {**key_to_jwk(key), "kid": "key-7"}, + declaration=declaration, pic_intent_digest=intent, pic_args_digest=args, + tool_call=tool_call, transcript=None, now=150, + ) + assert result["transcript_required"] is False + + contradictory = copy.deepcopy(no_transcript) + contradictory["successor_observation_digest"] = digest_jcs(transcript["after"]) + contradictory_bridge = sign_bridge(contradictory, key) + with pytest.raises(IntentBridgeError, match="must be absent"): + verify_bridge( + contradictory_bridge, {**key_to_jwk(key), "kid": "key-7"}, + declaration=declaration, pic_intent_digest=intent, pic_args_digest=args, + tool_call=tool_call, transcript=None, now=150, + ) + + def test_expiry_and_required_transcript_are_enforced() -> None: bridge, key, declaration, intent, args, tool_call, transcript = _fixture() with pytest.raises(IntentBridgeError, match="expired"): From 738e3d25e3766735390dfb5fc45720147d4b3ec1 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Mon, 14 Sep 2026 12:50:40 -0700 Subject: [PATCH 32/34] style(intent-bridge): wrap successor policy diagnostics --- src/agentrust_trace/intent_bridge.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/agentrust_trace/intent_bridge.py b/src/agentrust_trace/intent_bridge.py index 77c9f296..aca71b25 100644 --- a/src/agentrust_trace/intent_bridge.py +++ b/src/agentrust_trace/intent_bridge.py @@ -294,11 +294,13 @@ def verify_bridge( successor_digest_present = "successor_observation_digest" in authorization if authorization["transcript_required"] and not successor_digest_present: raise IntentBridgeError( - "authorization.successor_observation_digest is required when transcript_required is true" + "authorization.successor_observation_digest is required when " + "transcript_required is true" ) if not authorization["transcript_required"] and successor_digest_present: raise IntentBridgeError( - "authorization.successor_observation_digest must be absent when transcript_required is false" + "authorization.successor_observation_digest must be absent when " + "transcript_required is false" ) if authorization["transcript_required"]: if not isinstance(transcript, dict) or set(transcript) != {"before", "after"}: From 6e05c5f52112e4418c66ae19d4994a74ffa60237 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Mon, 14 Sep 2026 12:51:20 -0700 Subject: [PATCH 33/34] fix(intent-bridge): type successor binder at validation boundary --- src/agentrust_trace/intent_bridge.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agentrust_trace/intent_bridge.py b/src/agentrust_trace/intent_bridge.py index aca71b25..fe428bb5 100644 --- a/src/agentrust_trace/intent_bridge.py +++ b/src/agentrust_trace/intent_bridge.py @@ -111,7 +111,7 @@ def _nonempty_string(value: Any, field: str) -> str: def _bind_successor_observation( - after: dict[str, Any], expected_successor_digest: str + after: Any, expected_successor_digest: str ) -> dict[str, Any]: """Bind the exact successor envelope using the bridge identity relation. From fdc40f1d2ab8113838f6e8b20b7b8ad3506d9574 Mon Sep 17 00:00:00 2001 From: "Altru.dev" Date: Mon, 14 Sep 2026 13:35:03 -0700 Subject: [PATCH 34/34] test(intent-bridge): pin exact successor envelope shape --- tests/test_intent_bridge.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_intent_bridge.py b/tests/test_intent_bridge.py index 621eb9ce..ac0c5c2b 100644 --- a/tests/test_intent_bridge.py +++ b/tests/test_intent_bridge.py @@ -77,6 +77,23 @@ def test_successor_observation_is_bound_to_signed_authorization() -> None: ) +def test_successor_envelope_rejects_unknown_fields_even_when_signed() -> None: + bridge, key, declaration, intent, args, tool_call, transcript = _fixture() + extended = copy.deepcopy(transcript) + extended["after"]["extra"] = "signed-but-not-part-of-profile" + + authorization = copy.deepcopy(bridge["authorization"]) + authorization["successor_observation_digest"] = digest_jcs(extended["after"]) + resigned = sign_bridge(authorization, key) + + with pytest.raises(AuthorizationMismatch, match="unknown successor fields"): + verify_bridge( + resigned, {**key_to_jwk(key), "kid": "key-7"}, declaration=declaration, + pic_intent_digest=intent, pic_args_digest=args, tool_call=tool_call, + transcript=extended, now=150, + ) + + def test_caller_cannot_substitute_successor_and_matching_digest_without_resigning() -> None: bridge, key, declaration, intent, args, tool_call, transcript = _fixture() tampered = copy.deepcopy(bridge)