Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
849642c
prototype: evaluate bound successor observations
altrudev Sep 12, 2026
ff27c00
test: falsify successor observation conclusion rules
altrudev Sep 12, 2026
2599be9
docs: bound successor observation prototype
altrudev Sep 12, 2026
50a9154
prototype: bind complete successor envelope
altrudev Sep 12, 2026
b92b845
test: prevent successor metadata relabelling
altrudev Sep 12, 2026
4d1edf4
docs: bind successor provenance metadata
altrudev Sep 12, 2026
3585ca9
prototype: validate successor trust-policy inputs
altrudev Sep 12, 2026
c366e57
test: reject ambiguous observer policy containers
altrudev Sep 12, 2026
6a89044
prototype: fail closed on malformed trust policy
altrudev Sep 12, 2026
c731dbd
test: include null trust policy input
altrudev Sep 12, 2026
662e01f
test: register successor evaluator in public-surface sweep
altrudev Sep 12, 2026
1bc5a63
feat(intent-bridge): bind exact successor envelope
altrudev Sep 13, 2026
e0eb0fd
refactor(successor): use intent-bridge binding
altrudev Sep 13, 2026
ef4df1e
docs(intent-bridge): define successor assurance boundary
altrudev Sep 13, 2026
863ccab
fix(intent-bridge): preserve successor binding domain
altrudev Sep 13, 2026
fc31f9c
docs(intent-bridge): retire successor prototype note
altrudev Sep 13, 2026
c7a6572
docs(intent-bridge): keep informative wording non-normative
altrudev Sep 13, 2026
d22fe16
docs(successor): describe integrated assurance surface
altrudev Sep 13, 2026
117269e
docs(successor): describe integrated assurance surface
altrudev Sep 13, 2026
a83cb3b
fix(intent-bridge): authenticate successor observation binding
altrudev Sep 14, 2026
8b300c6
schema(intent-bridge): require signed successor observation digest
altrudev Sep 14, 2026
2bd1914
docs(intent-bridge): define authenticated successor binding
altrudev Sep 14, 2026
f858ed6
test(intent-bridge): cover authenticated successor binding
altrudev Sep 14, 2026
6c947fc
test(intent-bridge): simplify successor regression fixture
altrudev Sep 14, 2026
06f56cb
docs(successor): remove prototype ambiguity from binding contract
altrudev Sep 14, 2026
73d0fd8
test(successor): add freshness and empty-trust edge cases
altrudev Sep 14, 2026
333e7df
test(public-surface): update bridge fixture for successor binding
altrudev Sep 14, 2026
b772d42
fix(intent-bridge): scope successor digest to transcript-required aut…
altrudev Sep 14, 2026
8b43a94
schema(intent-bridge): require successor digest iff transcript is req…
altrudev Sep 14, 2026
d9ac670
test(schema): update bridge artifact for successor digest
altrudev Sep 14, 2026
30c05c2
test(intent-bridge): enforce successor digest iff transcript required
altrudev Sep 14, 2026
738e3d2
style(intent-bridge): wrap successor policy diagnostics
altrudev Sep 14, 2026
6e05c5f
fix(intent-bridge): type successor binder at validation boundary
altrudev Sep 14, 2026
fdc40f1
test(intent-bridge): pin exact successor envelope shape
altrudev Sep 14, 2026
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
65 changes: 62 additions & 3 deletions docs/integration/pic-trace-bridge-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,68 @@ 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

When `transcript_required` is true, `transcript.after` is the successor envelope and
has 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 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
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. 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
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`.
Expand Down
173 changes: 150 additions & 23 deletions schema/pic-trace-bridge-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +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", "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"},
"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}$"
}
}
}
80 changes: 75 additions & 5 deletions src/agentrust_trace/intent_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -105,6 +110,55 @@ def _nonempty_string(value: Any, field: str) -> str:
return value


def _bind_successor_observation(
after: 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
or observed_at > JCS_SAFE_INTEGER
):
raise IntentBridgeError(
"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:
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 digest binding"
)
return after


def _decision(value: Any) -> str:
"""Return a valid authorization decision or refuse a malformed value."""
Expand Down Expand Up @@ -158,10 +212,11 @@ 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",
}
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"):
Expand Down Expand Up @@ -236,6 +291,17 @@ 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")
Expand All @@ -255,6 +321,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
Loading