diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d6ee83..8e6baed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +## [0.51.0] - 2026-06-02 + +**Theme: SEP-2828 Check B. A decision and an outcome now pair on two checks, not +one. Instance binding (the shared attestation back-link) stays the anchor, and +the outcome record additionally commits to a digest of the exact decision it ran +under. Instance binding alone could not tell which decision a call answered when +several shared an attestation, for example an `escalate` and the human verdict +that superseded it; the content digest closes that gap.** + +### Added +- `outcomeDerived.decisionDigest` on the execution receipt: `sha256:` over + the full signed decision-record wire bytes the outcome was produced under. The + field is additive (wire `version` stays `1`); a v0.51 emitter sets it and + pairing fails without it. +- `vaara.attestation.decision.decision_digest`: the Check B digest input. +- `vaara.attestation.decision.superseding_decision`: resolves the effective + decision among records sharing a back-link (latest `decidedAt`, ties broken by + lowest lexicographic `issuerAsserted.nonce`, so every verifier agrees with no + clock authority). +- A seventh conformance vector, `substituted_decision_under_shared_attestation`, + isolating the case Check A cannot catch, plus the resolved supersession-tie + winner. The standard-library walker reproduces both from the wire bytes. + +### Changed +- `records_paired` now requires both Check A (instance anchor) and Check B + (outcome-to-decision digest). A receipt with no `decisionDigest` does not pair. + ## [0.50.0] - 2026-06-01 **Theme: the verifiable evidence plane. Trail exports can be threshold-signed so diff --git a/clients/ts/package.json b/clients/ts/package.json index 2e21dd6..400915a 100644 --- a/clients/ts/package.json +++ b/clients/ts/package.json @@ -1,6 +1,6 @@ { "name": "@vaara/client", - "version": "0.50.0", + "version": "0.51.0", "mcpName": "io.github.vaaraio/vaara", "description": "TypeScript client for the Vaara HTTP API: EU AI Act runtime evidence for MCP tool calls. Conformal risk scoring, policy gating, hash-chained tamper-evident audit, named detectors.", "main": "dist/index.js", diff --git a/docs/sep/sep-server-execution-record.md b/docs/sep/sep-server-execution-record.md index 9c59d7b..cf78233 100644 --- a/docs/sep/sep-server-execution-record.md +++ b/docs/sep/sep-server-execution-record.md @@ -8,7 +8,7 @@ - **Status**: Draft - **Type**: Standards Track (Extensions Track) - **Created**: 2026-05-31 -- **Author(s)**: vaaraio (@vaaraio) +- **Author(s)**: Henri Sirkkavaara (@vaaraio), Vaara - **Sponsor**: None (seeking sponsor) - **PR**: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/XXXX - **Requires**: SEP-2787 (Tool call attestation) @@ -198,7 +198,11 @@ only its content. A `decision` of `escalate` means the call was held for human oversight; the outcome record for that call will report `refused` if the human declined, or a -later decision record MAY supersede it (see Pairing). +later decision record MAY supersede it (see Pairing). The `decision` enum is +closed at three values. Host- or framework-specific labels for "ask a human" +(for example `refer`, or an `AskUser` tool-call interrupt) are not wire values; +they MUST be recorded as `escalate`. The resolving human verdict is a new +decision record (`allow` or `block`) that supersedes the `escalate`. **`issuerAsserted`** is the governing server's signed block: @@ -234,18 +238,32 @@ already shipping as `vaara.attestation.receipt.ExecutionReceipt`. | `status` | string | yes | One of `executed`, `refused`, `errored`. | | `completedAt` | string | yes | ISO 8601 UTC completion (or refusal) time. | | `resultCommitment` | object | no | An `ArgsRef` or `ArgsProjection` over the result (executed) or error object (errored). Absent for `refused`, which has no result. RECOMMENDED to use the commitment-only hash-only-identity projection so result payloads, which may contain personal data, are not copied into the record. | +| `decisionDigest` | string | yes\* | `sha256:` over the JCS-canonical full decision-record wire bytes (signature included) the outcome was produced under. This is the Check B (outcome-to-decision) binding. \*Optional on the wire for backward parsing of pre-v0.51 records, but a v0.51 emitter MUST set it and pairing fails without it (see Pairing). | ### Pairing -A decision record and an outcome record describe the same governed call when -both carry the same `backLink` (`attestationDigest` and `attestationNonce` -equal). A verifier that has both, plus the SEP-2787 attestation, can confirm the -full chain: the attestation pins the call; the decision record pins the policy -verdict and risk basis for that call; the outcome record pins what the call did. -This is instance-binding, not only content-binding: two byte-identical calls -produce two attestations with distinct nonces and therefore distinct -`attestationDigest` values, so a decision or outcome record cannot be replayed -against a different instance of the same call. +A decision record and an outcome record pair when **both** of the following +hold: + +- **Check A (instance anchor).** Both records carry the same `backLink` + (`attestationDigest` and `attestationNonce` equal). This is instance-binding, + not only content-binding: two byte-identical calls produce two attestations + with distinct nonces and therefore distinct `attestationDigest` values, so a + record cannot be replayed against a different instance of the same call. In the + no-attestation fallback, the shared `backLink` is over the request envelope + instead, and Check A anchors on that. +- **Check B (outcome-to-decision digest, the normative pairing).** The outcome + record's `outcomeDerived.decisionDigest` equals `sha256:` over the JCS + canonical full wire bytes of *this* decision record. Check A alone admits a + different decision taken under the same attestation instance (an `escalate` + and the human verdict that supersedes it both share the attestation); Check B + pins which decision's content the outcome answers. An outcome record without + `decisionDigest` does not pair: content binding is mandatory, not best-effort. + +A verifier that has both records, plus the SEP-2787 attestation, can then confirm +the full chain: the attestation pins the call; the decision record pins the +policy verdict and risk basis; the outcome record pins what the call did and the +decision it ran under. For correlation with client-asserted input context (SEP-2817), a server MAY include the SEP-2817 `turnId` as an additional, clearly client-asserted field @@ -256,8 +274,13 @@ this `turnId`, not that the server vouches for it. A superseding decision (for example, a human resolving an `escalate`) is recorded as a new decision record with the same `backLink` and a later `decidedAt`. The record with the latest `decidedAt` for a given `backLink` is the effective -decision; earlier ones are retained as history. Verifiers MUST NOT treat -multiple decision records for one `backLink` as a conflict. +decision; earlier ones are retained as history. When two records for one +`backLink` carry the **same** `decidedAt`, the tie MUST break deterministically: +the effective record is the one whose `issuerAsserted.nonce` is lexicographically +lowest. This gives every verifier the same winner with no clock authority. +Verifiers MUST NOT treat multiple decision records for one `backLink` as a +conflict. The outcome record's `decisionDigest` (Check B) identifies which +decision in this set the call actually ran under. ### Transport @@ -317,6 +340,7 @@ Outcome record (executed, result committed by digest only): "outcomeDerived": { "status": "executed", "completedAt": "2026-05-31T09:30:02Z", + "decisionDigest": "sha256:7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f8f1e2c0a9b", "resultCommitment": { "projection": "{\"digest\":\"sha256:1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c\"}", "projectionDigest": "sha256:aa11bb22cc33dd44ee55ff66aa77bb88cc99dd00ee11ff22aa33bb44cc55dd66" @@ -372,14 +396,24 @@ MUST perform, and a conforming implementation MUST pass, the following checks: 2. Recompute `attestationDigest` from the SEP-2787 attestation wire bytes and confirm both records' `backLink.attestationDigest` and `backLink.attestationNonce` match it. Reject on mismatch. -3. Confirm the two records share the same `backLink` (they describe the same - call). -4. If `resultCommitment` is present and the verifier has the result payload, +3. Confirm the two records share the same `backLink` (Check A: they describe the + same call instance). Reject on mismatch. +4. Recompute `sha256:` over the JCS-canonical full wire bytes of the + decision record and confirm it equals the outcome record's + `outcomeDerived.decisionDigest` (Check B: the outcome was produced under this + decision). Reject if `decisionDigest` is absent or does not match. +5. If `resultCommitment` is present and the verifier has the result payload, recompute the commitment digest from the runtime result and confirm it matches. Reject on mismatch. -5. Confirm `decisionDerived.decision` is one of `allow`, `block`, `escalate` and +6. Confirm `decisionDerived.decision` is one of `allow`, `block`, `escalate` and `outcomeDerived.status` is one of `executed`, `refused`, `errored`. +When more than one decision record shares a `backLink`, the verifier resolves the +effective decision by latest `decidedAt`, breaking an exact-`decidedAt` tie by +lowest lexicographic `issuerAsserted.nonce`. The outcome's `decisionDigest` +selects which decision in the set the call ran under, which need not be the +effective one (a call can run under an `escalate` that was later superseded). + ## Rationale **Two records, not one.** The decision and the outcome are made at different @@ -430,6 +464,13 @@ adding the `decisionDerived` block and the `outcomeDerived.status` enum, with no change to the canonicalization or signature code. The records do not alter any existing MCP request or response method. +The `outcomeDerived.decisionDigest` field (Check B) is additive: the record +schema `version` stays `1`, and a parser reads records with or without it. The +contract change is in the verifier, not the wire format: a conforming verifier +requires `decisionDigest` for a pair to be valid, so an emitter that wants its +outcomes to pair MUST set it. Records that only ever assert Check A (instance +binding) still parse and still verify their signatures and back-links. + ## Security Implications **Signing-key compromise and post-hoc backdating.** A signed record proves the @@ -506,11 +547,15 @@ call and counts emission failures for operator alerting. - **agent-guard (XuebinMa, Rust).** Models a `Guard` producing an `AuditRecord` and an `ExecutionProof`. The two-record decision/outcome split here covers the - same ground as `AuditRecord` plus `ExecutionProof`, with the binding made - explicit and instance-scoped through the SEP-2787 attestation digest rather - than left to a content hash. The instance-binding position was conceded by the - agent-guard author in discussion on 2026-05-30 and is adopted here as - normative. + same ground as `AuditRecord` plus `ExecutionProof`. The two are reconciled by + using both: instance binding through the SEP-2787 attestation digest is the + anchor (Check A), and the agent-guard author's outcome-to-decision content + digest is adopted as the normative pairing key (Check B, + `outcomeDerived.decisionDigest`). Instance binding alone cannot say which + decision a call ran under when several share an attestation; the content digest + alone reintroduces cross-instance replay. Requiring both closes each gap. The + instance-binding-as-anchor position was conceded by the agent-guard author on + 2026-05-30; Check B incorporates the content-digest join the author proposed. - **Content-addressed receipt identifiers.** Some designs identify a record by a content-addressed id of the form `action_ref = sha256(JCS(...))` over a @@ -518,19 +563,23 @@ call and counts emission failures for operator alerting. default join field, because it is content-binding and reintroduces cross-instance replay when used as the primary binding. Such a pointer is reconcilable as an optional `ArgsRef`-shaped reference (the `ref` carries the id - and the `digest` pins it), not as the normative pairing key, which remains the - instance-scoped `backLink`. + and the `digest` pins it). It is not the join key on its own: pairing requires + the instance-scoped `backLink` (Check A) as well as the outcome-to-decision + `decisionDigest` (Check B), so a content-addressed id never stands in for + instance binding. ## Reference Implementation -The wire schema in this SEP is the shape shipping in the Vaara MCP proxy -(v0.48; the receipt library landed in v0.42). Relevant modules: +The wire schema in this SEP is the shape shipping in the Vaara MCP proxy (the +receipt library landed in v0.42; the Check B `decisionDigest` binding and the +supersession tie-break landed in v0.51). Relevant modules: - `vaara/attestation/_receipt_types.py`: the `ExecutionReceipt` envelope (`version`, `alg`, `backLink`, `outcomeDerived`, `receiptAsserted`, `signature`), `OutcomeDerived` with `status` constrained to `executed` / - `refused` / `errored`, and `BackLink` (`attestationDigest`, - `attestationNonce`). This is the outcome record of this SEP, byte for byte. + `refused` / `errored` and the optional `decisionDigest` (Check B), and + `BackLink` (`attestationDigest`, `attestationNonce`). This is the outcome + record of this SEP, byte for byte. - `vaara/attestation/_receipt_emit.py`: builds, JCS-canonicalizes (signing input excludes `signature`), and signs the outcome record; verifies the signature. - `vaara/attestation/_receipt_verifier.py`: `make_back_link` / `verify_back_link` @@ -538,10 +587,13 @@ The wire schema in this SEP is the shape shipping in the Vaara MCP proxy bytes, signature included): the instance-binding join. - `vaara/attestation/decision.py`: the decision record of this SEP. `DecisionRecord` / `DecisionDerived`, `emit_decision_record`, - `verify_decision_signature`, `verify_decision_back_link`, and - `records_paired` (the decision-and-outcome join). Reuses the receipt's - back-link, the issuer-block layout, and the shared signing stack unchanged, - so the decision record adds the `decisionDerived` block and no new crypto. + `verify_decision_signature`, `verify_decision_back_link`, `decision_digest` + (sha256 over the JCS-canonical full decision wire bytes, the Check B input), + `records_paired` (the decision-and-outcome join, enforcing Check A and Check + B), and `superseding_decision` (latest `decidedAt`, tie-broken by lowest + `issuerAsserted.nonce`). Reuses the receipt's back-link, the issuer-block + layout, and the shared signing stack unchanged, so the decision record adds + the `decisionDerived` block and no new crypto. - `vaara/attestation/_sep2787_types.py` and `_sep2787_canonical.py`: the shared commitment shapes (`ArgsRef`, `ArgsProjection`, `make_args_digest`), the issuer-block layout, RFC 8785 canonicalization with float rejection, and the @@ -578,20 +630,24 @@ tolerated internally. The Vaara conformance vectors for the SEP-2787 canonicalization and signature (`modelcontextprotocol/modelcontextprotocol#2789`) cover the JCS encoding, float rejection, and the detached-signature scheme that both records in this SEP reuse. -A standard-library-only checker (no Vaara import; `cryptography` for the -asymmetric case) verifies a signed export offline, mirroring the -`scripts/verify_vaara_trail.py` approach. For Standards Track finalization, this -SEP will add: - -- JCS canonical vectors for the `decisionDerived` block and the - `outcomeDerived.status` enum, in the same vector format as #2789. -- A paired decision-plus-outcome fixture with its SEP-2787 attestation, exercising - the full verification algorithm above, including a replay-rejection case - (mismatched `backLink`) and a superseding-decision case (two decision records, - same `backLink`, distinct `decidedAt`). -- A `sep-XXXX.yaml` traceability file mapping each MUST / MUST NOT and - SHOULD / SHOULD NOT in the Specification to a conformance check ID, as required - for Standards Track SEPs reaching Final. + +The decision/outcome pairing vectors are committed at +`tests/vectors/decision_pairing_v0/` with a standard-library-only checker +(`_check_independent.py`: `cryptography` and `rfc8785`, no Vaara import) that +reproduces every verdict from the wire bytes alone. The seven normative cases +exercise the full verification algorithm above: a valid paired allow/executed, a +decision-only escalate, the two replay-rejection cases (substituted attestation +back-link and substituted pairing nonce, both failing Check A), a substituted +decision under a shared attestation (Check A passes, Check B fails), the +equal-`decidedAt` supersession tie resolved by lowest `issuerAsserted.nonce`, and +the no-SEP-2787 fallback request-envelope binding. An independent consumer +verifier (Rul1an/Assay) reproduces the Check-A subset today and the Check-B and +supersession cases as it adopts the digest and ordering model. + +For Standards Track finalization, this SEP will add a `sep-XXXX.yaml` +traceability file mapping each MUST / MUST NOT and SHOULD / SHOULD NOT in the +Specification to a conformance check ID, as required for Standards Track SEPs +reaching Final. ## Acknowledgments diff --git a/pyproject.toml b/pyproject.toml index 72d43cf..e9f5579 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "vaara" -version = "0.50.0" +version = "0.51.0" description = "Runtime evidence layer for AI agents under the EU AI Act: policy-gated tool calls, hash-chained tamper-evident audit trails with external time anchoring, and independently verifiable attestation plus execution receipts per MCP tool call" requires-python = ">=3.10" license = "Apache-2.0" diff --git a/scripts/generate_decision_pairing_vectors.py b/scripts/generate_decision_pairing_vectors.py index 6dade73..65f2512 100644 --- a/scripts/generate_decision_pairing_vectors.py +++ b/scripts/generate_decision_pairing_vectors.py @@ -7,12 +7,13 @@ reproduce from the committed wire bytes alone. These are the SEP-owned fixtures requested on modelcontextprotocol/modelcontextprotocol#2828. -Pairing model is the one the reference impl ships: a decision and a -receipt pair when both carry the same attestation back-link -(``records_paired``: attestationDigest constant-time equal AND -attestationNonce equal). The "outcome resolves to the decision content -digest" join discussed in #2828 is a distinct contract and is NOT -exercised here; adopting it normatively adds a field and a case. +Pairing model (SEP-2828, v0.51) is two checks. Check A is the instance +anchor: a decision and a receipt share the same attestation back-link +(attestationDigest constant-time equal AND attestationNonce equal). +Check B is the normative outcome-to-decision binding: the receipt's +``outcomeDerived.decisionDigest`` equals ``sha256`` over the full signed +decision wire bytes. ``records_paired`` requires both, so a receipt that +shares an attestation but commits to a different decision does not pair. Usage: python scripts/generate_decision_pairing_vectors.py """ @@ -28,7 +29,7 @@ from vaara.attestation._sep2787_canonical import canonical_json from vaara.attestation.decision import ( - DecisionDerived, emit_decision_record, make_back_link, + DecisionDerived, decision_digest, emit_decision_record, make_back_link, ) from vaara.attestation.receipt import ( BackLink, OutcomeDerived, emit_receipt, make_result_digest, @@ -73,11 +74,12 @@ def _decision(bl, verdict, *, key, alg, nonce): alg=alg, signing_material=key, nonce=nonce, **COMMON) -def _receipt(bl, status, *, key, alg, nonce, commit=True): +def _receipt(bl, status, *, key, alg, nonce, commit=True, dec_digest=None): return emit_receipt( back_link=bl, outcome_derived=OutcomeDerived( status=status, completed_at=IAT, - result_commitment=make_result_digest(RESULT) if commit else None), + result_commitment=make_result_digest(RESULT) if commit else None, + decision_digest=dec_digest), alg=alg, signing_material=key, nonce=nonce, **COMMON) @@ -117,10 +119,13 @@ def case(name, *, decision, receipt=None, expected): _write(d / "receipt.json", receipt.to_dict()) _write(d / "expected.json", expected) - # 1. Valid allow + executed, paired on the same attestation back-link. + # 1. Valid allow + executed: paired on the same attestation back-link + # (Check A) AND the receipt commits to the decision digest (Check B). + dec1 = _decision(bl, "allow", key=HS, alg="HS256", nonce="d1") case("valid_pair_allow_executed", - decision=_decision(bl, "allow", key=HS, alg="HS256", nonce="d1"), - receipt=_receipt(bl, "executed", key=HS, alg="HS256", nonce="r1"), + decision=dec1, + receipt=_receipt(bl, "executed", key=HS, alg="HS256", nonce="r1", + dec_digest=decision_digest(dec1)), expected={"decision_signature_ok": True, "decision_back_link_ok": True, "receipt_signature_ok": True, "receipt_back_link_ok": True, "records_paired": True}) @@ -132,29 +137,33 @@ def case(name, *, decision, receipt=None, expected): "receipt_present": False, "outcome_required": False}) # 3. Substituted attestation back-link: receipt binds a different - # attestation. Both verify alone; they do not pair. + # attestation. Both verify alone; Check A rejects the pair. other = _attestation(nonce="other-attestation-nonce-999") + dec3 = _decision(bl, "allow", key=HS, alg="HS256", nonce="d3") case("substituted_attestation_backlink", - decision=_decision(bl, "allow", key=HS, alg="HS256", nonce="d3"), + decision=dec3, receipt=_receipt(make_back_link(other), "executed", key=HS, - alg="HS256", nonce="r3"), + alg="HS256", nonce="r3", dec_digest=decision_digest(dec3)), expected={"decision_signature_ok": True, "decision_back_link_ok": True, "receipt_signature_ok": True, "receipt_back_link_ok_against_stored_attestation": False, "records_paired": False}) # 4. Substituted pairing nonce: same digest, mismatched nonce. The - # nonce is the pairing link, so the pair is rejected. + # nonce is part of Check A, so the pair is rejected. tampered = BackLink(attestation_digest=bl.attestation_digest, attestation_nonce="substituted-nonce") + dec4 = _decision(bl, "allow", key=HS, alg="HS256", nonce="d4") case("substituted_pairing_nonce", - decision=_decision(bl, "allow", key=HS, alg="HS256", nonce="d4"), - receipt=_receipt(tampered, "executed", key=HS, alg="HS256", nonce="r4"), + decision=dec4, + receipt=_receipt(tampered, "executed", key=HS, alg="HS256", nonce="r4", + dec_digest=decision_digest(dec4)), expected={"records_paired": False, "note": "same attestationDigest, mismatched attestationNonce"}) - # 5. Equal-decidedAt supersession tie. The reference impl has NO - # supersession ordering today; this pins the OPEN contract question. + # 5. Equal-decidedAt supersession tie. Resolved in v0.51: latest + # decidedAt wins; on a tie, lowest issuerAsserted.nonce wins. Both + # share decidedAt, so the winner is the lower nonce, "d5a". d5 = OUT / "normative" / "supersession_equal_decidedat_tie" _write(d5 / "attestation.json", att.to_dict()) _write(d5 / "decision_a.json", @@ -162,17 +171,19 @@ def case(name, *, decision, receipt=None, expected): _write(d5 / "decision_b.json", _decision(bl, "allow", key=HS, alg="HS256", nonce="d5b").to_dict()) _write(d5 / "expected.json", { - "both_signatures_ok": True, "both_back_links_ok": True, "winner": None, - "open_contract": "equal decidedAt needs a deterministic tie-break " - "(e.g. lexicographic on record nonce); unspecified " - "in the reference impl as of v0.50.0"}) + "both_signatures_ok": True, "both_back_links_ok": True, "winner": "d5a", + "note": "equal decidedAt resolves by lexicographic issuerAsserted.nonce, " + "lowest wins (d5a < d5b)"}) - # 6. Fallback request-envelope binding, replay/substitution. + # 6. Fallback request-envelope binding, replay/substitution. No + # SEP-2787 attestation, so Check A anchors on the request-envelope + # digest; Check B still binds the outcome to the decision. fb = _fallback_backlink() d6 = OUT / "normative" / "fallback_envelope_binding" - _write(d6 / "decision.json", - _decision(fb, "allow", key=HS, alg="HS256", nonce="d6").to_dict()) - rec = _receipt(fb, "executed", key=HS, alg="HS256", nonce="r6") + dec6 = _decision(fb, "allow", key=HS, alg="HS256", nonce="d6") + _write(d6 / "decision.json", dec6.to_dict()) + rec = _receipt(fb, "executed", key=HS, alg="HS256", nonce="r6", + dec_digest=decision_digest(dec6)) _write(d6 / "receipt.json", rec.to_dict()) replayed = rec.to_dict() replayed["backLink"]["attestationDigest"] = "sha256:" + "0" * 64 @@ -183,6 +194,22 @@ def case(name, *, decision, receipt=None, expected): "note": "fallback binding when no SEP-2787 attestation exists; the " "BackLink digest is over the JCS-canonical request envelope"}) + # 7. Check B in isolation: the receipt shares the attestation back-link + # (Check A passes) but commits to a DIFFERENT decision's digest, so + # presenting the substituted decision does not pair. This is the + # case instance-binding alone (Check A) cannot catch. + dec7_bound = _decision(bl, "allow", key=HS, alg="HS256", nonce="d7a") + dec7_other = _decision(bl, "block", key=HS, alg="HS256", nonce="d7b") + case("substituted_decision_under_shared_attestation", + decision=dec7_other, + receipt=_receipt(bl, "executed", key=HS, alg="HS256", nonce="r7", + dec_digest=decision_digest(dec7_bound)), + expected={"decision_signature_ok": True, "receipt_signature_ok": True, + "shared_back_link": True, "records_paired": False, + "note": "Check A (shared attestation back-link) passes; " + "Check B fails: the receipt's decisionDigest commits " + "to a different decision than the one presented"}) + print(f"wrote vectors under {OUT}") diff --git a/src/vaara/__init__.py b/src/vaara/__init__.py index 26a5904..47cd4f8 100644 --- a/src/vaara/__init__.py +++ b/src/vaara/__init__.py @@ -6,7 +6,7 @@ oversight. """ -__version__ = "0.50.0" +__version__ = "0.51.0" from vaara.pipeline import InterceptionPipeline, InterceptionResult diff --git a/src/vaara/attestation/_decision_verifier.py b/src/vaara/attestation/_decision_verifier.py index 3b5e44c..75bf596 100644 --- a/src/vaara/attestation/_decision_verifier.py +++ b/src/vaara/attestation/_decision_verifier.py @@ -16,7 +16,9 @@ from __future__ import annotations +import hashlib import hmac +from collections.abc import Sequence from vaara.attestation._decision_types import DecisionRecord from vaara.attestation._receipt_types import ExecutionReceipt @@ -25,9 +27,22 @@ BackLinkResult, attestation_digest, ) +from vaara.attestation._sep2787_canonical import canonical_json from vaara.attestation._sep2787_types import Attestation +def decision_digest(record: DecisionRecord) -> str: + """``sha256:`` over the JCS-canonical full decision wire bytes. + + The signature is included, mirroring ``attestation_digest``: the + digest pins the exact decision-record instance, so an outcome record + that commits to it (SEP-2828 Check B) is bound to one decision's + content, not merely to a record with the same fields. + """ + wire = canonical_json(record.to_dict()) + return f"sha256:{hashlib.sha256(wire).hexdigest()}" + + def verify_decision_back_link( record: DecisionRecord, *, @@ -56,17 +71,60 @@ def records_paired( ) -> bool: """True iff a decision record and an execution receipt describe one call. - They pair when both carry the same back-link: the attestation digest - (constant-time compared) and the attestation nonce both agree. This - is instance-binding, not content-binding, so two byte-identical calls - produce distinct attestations and therefore do not cross-pair. + Both SEP-2828 checks must hold: + + - **Check A (instance anchor).** Both records carry the same + back-link: the attestation digest (constant-time compared) and the + attestation nonce both agree. This is instance-binding, so two + byte-identical calls produce distinct attestations and do not + cross-pair. It also anchors the fallback case, where the back-link + is over the request envelope rather than a SEP-2787 attestation. + - **Check B (outcome-to-decision digest, normative pairing).** The + receipt's ``outcomeDerived.decisionDigest`` equals the digest of + *this* decision record. Check A alone admits a different decision + made under the same attestation (e.g. a superseding verdict); Check + B pins which decision's content the outcome answers. A receipt + without ``decisionDigest`` does not pair: content binding is + mandatory, not best-effort. """ + # Check A: same call instance. if not hmac.compare_digest( decision.back_link.attestation_digest, receipt.back_link.attestation_digest, ): return False - return ( + if ( decision.back_link.attestation_nonce - == receipt.back_link.attestation_nonce - ) + != receipt.back_link.attestation_nonce + ): + return False + # Check B: outcome commits to this decision's content. + bound = receipt.outcome_derived.decision_digest + if bound is None: + return False + return hmac.compare_digest(bound, decision_digest(decision)) + + +def superseding_decision( + decisions: Sequence[DecisionRecord], +) -> DecisionRecord: + """Return the effective decision among records for one call. + + A superseding decision (for example a human resolving an + ``escalate``) is a new decision record with the same back-link and a + later ``decidedAt``; earlier records are retained as history. The + record with the latest ``decidedAt`` is effective. When two share the + same ``decidedAt``, the tie breaks deterministically on the issuer + nonce, lowest lexicographic wins, so every verifier selects the same + winner without a clock. + + The caller is responsible for passing records that share a back-link; + this resolves ordering only. Raises ``ValueError`` on an empty input. + """ + if not decisions: + raise ValueError("superseding_decision requires at least one record") + latest = max(d.decision_derived.decided_at for d in decisions) + tied = [ + d for d in decisions if d.decision_derived.decided_at == latest + ] + return min(tied, key=lambda d: d.issuer_asserted.nonce) diff --git a/src/vaara/attestation/_receipt_types.py b/src/vaara/attestation/_receipt_types.py index a385757..89653b6 100644 --- a/src/vaara/attestation/_receipt_types.py +++ b/src/vaara/attestation/_receipt_types.py @@ -99,11 +99,20 @@ class OutcomeDerived: is optional: a refused call has no result, so the commitment is absent. An executed or errored call commits to the result or the error object respectively. + + ``decision_digest`` is the SEP-2828 Check B (outcome-to-decision) + binding: ``sha256:`` over the full signed decision-record wire + bytes the outcome was produced under. It is optional on the type so + pre-v0.51 receipts and the no-attestation fallback still parse, but + v0.51 emitters MUST set it and pairing (``records_paired``) fails + without it. Where ``backLink`` pins the call instance (Check A), this + pins which decision's content the outcome answers. """ status: ReceiptStatus completed_at: str result_commitment: Optional[ResultCommitment] = None + decision_digest: Optional[str] = None @dataclass(frozen=True) @@ -159,6 +168,8 @@ def outcome_to_dict(od: OutcomeDerived) -> dict[str, Any]: } if od.result_commitment is not None: out["resultCommitment"] = args_to_dict(od.result_commitment) + if od.decision_digest is not None: + out["decisionDigest"] = od.decision_digest return out @@ -203,10 +214,16 @@ def outcome_from_dict(d: dict[str, Any]) -> OutcomeDerived: if "resultCommitment" in d else None ) + decision_digest = d.get("decisionDigest") + if decision_digest is not None and not decision_digest.startswith("sha256:"): + raise AttestationError( + "outcomeDerived.decisionDigest MUST be a 'sha256:' digest" + ) return OutcomeDerived( status=d["status"], completed_at=d["completedAt"], result_commitment=commitment, + decision_digest=decision_digest, ) diff --git a/src/vaara/attestation/decision.py b/src/vaara/attestation/decision.py index b8f2cc4..69dbf39 100644 --- a/src/vaara/attestation/decision.py +++ b/src/vaara/attestation/decision.py @@ -45,7 +45,9 @@ decision_record_from_dict as parse_decision_record, ) from vaara.attestation._decision_verifier import ( + decision_digest, records_paired, + superseding_decision, verify_decision_back_link, ) from vaara.attestation._receipt_types import BackLink @@ -63,10 +65,12 @@ "DecisionVerdict", "IssuerAsserted", "attestation_digest", + "decision_digest", "emit_decision_record", "make_back_link", "parse_decision_record", "records_paired", + "superseding_decision", "verify_decision_back_link", "verify_decision_signature", ] diff --git a/tests/test_decision_record.py b/tests/test_decision_record.py index 6dad7f7..a4ce8bc 100644 --- a/tests/test_decision_record.py +++ b/tests/test_decision_record.py @@ -24,10 +24,12 @@ from vaara.attestation.decision import ( # noqa: E402 DecisionDerived, + decision_digest, emit_decision_record, make_back_link, parse_decision_record, records_paired, + superseding_decision, verify_decision_back_link, verify_decision_signature, ) @@ -201,15 +203,19 @@ def test_wire_round_trip(): assert verify_decision_back_link(reparsed, attestation=att).ok is True -def test_decision_and_outcome_pair_on_shared_attestation(): - att = _attestation() - decision = _emit(att) - receipt = emit_receipt( +def _outcome_receipt(att, decision, *, status="executed", dec_digest=...): + """Emit a receipt back-linked to ``att``. ``dec_digest`` defaults to + the digest of ``decision`` (Check B satisfied); pass ``None`` to omit + it or a string to forge it.""" + if dec_digest is ...: + dec_digest = decision_digest(decision) + return emit_receipt( back_link=make_back_link(att), outcome_derived=OutcomeDerived( - status="executed", + status=status, completed_at="2026-05-31T09:30:02Z", result_commitment=make_result_digest({"ok": True}), + decision_digest=dec_digest, ), iss="vaara-proxy://acme-eu", sub="tenant:acme/agent:billing-bot", @@ -217,9 +223,78 @@ def test_decision_and_outcome_pair_on_shared_attestation(): alg="HS256", signing_material=HS_SECRET, ) + + +def test_decision_and_outcome_pair_on_shared_attestation(): + att = _attestation() + decision = _emit(att) + receipt = _outcome_receipt(att, decision) assert records_paired(decision, receipt) is True +def test_receipt_without_decision_digest_does_not_pair(): + # Check B is mandatory: shared attestation (Check A) is not enough. + att = _attestation() + decision = _emit(att) + receipt = _outcome_receipt(att, decision, dec_digest=None) + assert records_paired(decision, receipt) is False + + +def test_receipt_bound_to_other_decision_does_not_pair(): + # Same attestation, but the outcome commits to a different decision's + # content. Check A passes, Check B rejects. + att = _attestation() + bound = _emit(att) + presented = emit_decision_record( + back_link=make_back_link(att), + decision_derived=DecisionDerived( + decision="block", decided_at="2026-05-31T09:30:01Z"), + iss="vaara-proxy://acme-eu", + sub="tenant:acme/agent:billing-bot", + secret_version="2026-05", + alg="HS256", + signing_material=HS_SECRET, + ) + receipt = _outcome_receipt(att, bound) # commits to `bound`, not `presented` + assert decision_digest(bound) != decision_digest(presented) + assert records_paired(bound, receipt) is True + assert records_paired(presented, receipt) is False + + +def test_decision_digest_is_deterministic_and_instance_bound(): + att = _attestation() + d1 = _emit(att) + assert decision_digest(d1) == decision_digest(parse_decision_record(d1.to_dict())) + assert decision_digest(d1).startswith("sha256:") + d2 = _emit(_attestation()) # fresh attestation nonce -> distinct instance + assert decision_digest(d1) != decision_digest(d2) + + +def test_superseding_decision_latest_wins_then_lowest_nonce(): + att = _attestation() + bl = make_back_link(att) + + def _dec(decided_at, nonce): + return emit_decision_record( + back_link=bl, + decision_derived=DecisionDerived( + decision="allow", decided_at=decided_at), + iss="vaara-proxy://acme-eu", sub="tenant:acme/agent:billing-bot", + secret_version="2026-05", alg="HS256", signing_material=HS_SECRET, + nonce=nonce) + + earlier = _dec("2026-05-31T09:00:00Z", "n-zzz") + later = _dec("2026-05-31T10:00:00Z", "n-mmm") + assert superseding_decision([earlier, later]) is later + + # Equal decidedAt: lowest lexicographic issuerAsserted.nonce wins. + tie_a = _dec("2026-05-31T11:00:00Z", "n-aaa") + tie_b = _dec("2026-05-31T11:00:00Z", "n-bbb") + assert superseding_decision([tie_b, tie_a]) is tie_a + with pytest.raises(ValueError): + superseding_decision([]) + + def test_records_from_different_calls_do_not_pair(): decision = _emit(_attestation()) other_att = _attestation() # fresh nonce diff --git a/tests/vectors/decision_pairing_v0/_check_independent.py b/tests/vectors/decision_pairing_v0/_check_independent.py index 5819fe1..bb7c0ba 100644 --- a/tests/vectors/decision_pairing_v0/_check_independent.py +++ b/tests/vectors/decision_pairing_v0/_check_independent.py @@ -80,11 +80,33 @@ def verify_back_link(record: dict, attestation: dict) -> bool: return bl["attestationNonce"] == attestation["issuerAsserted"]["nonce"] +def decision_digest(decision: dict) -> str: + """sha256 over the full signed decision wire bytes (SEP-2828 Check B).""" + return _sha256_hex(_jcs(decision)) + + def records_paired(decision: dict, receipt: dict) -> bool: + # Check A: same call instance (shared attestation back-link). db, rb = decision["backLink"], receipt["backLink"] if not hmac.compare_digest(db["attestationDigest"], rb["attestationDigest"]): return False - return db["attestationNonce"] == rb["attestationNonce"] + if db["attestationNonce"] != rb["attestationNonce"]: + return False + # Check B: the outcome commits to this decision's digest. + bound = receipt["outcomeDerived"].get("decisionDigest") + if bound is None: + return False + return hmac.compare_digest(bound, decision_digest(decision)) + + +def superseding_nonce(decisions: list) -> str: + """issuerAsserted.nonce of the effective decision: latest decidedAt, + tie-broken by lowest lexicographic issuerAsserted.nonce.""" + latest = max(d["decisionDerived"]["decidedAt"] for d in decisions) + tied = [d for d in decisions + if d["decisionDerived"]["decidedAt"] == latest] + return min(tied, key=lambda d: d["issuerAsserted"]["nonce"])[ + "issuerAsserted"]["nonce"] def _load(case: Path, name: str): @@ -94,7 +116,7 @@ def _load(case: Path, name: str): # Declarative keys carried in expected.json that are documentation, not # crypto verdicts; the checker passes them through verbatim. -_DOC_KEYS = {"outcome_required", "winner", "open_contract", "note"} +_DOC_KEYS = {"outcome_required", "open_contract", "note"} def _verdicts(case: Path, expected: dict) -> dict: @@ -116,6 +138,11 @@ def _verdicts(case: Path, expected: dict) -> dict: got[key] = verify_back_link(rec, att) elif key == "records_paired": got[key] = records_paired(dec, rec) + elif key == "shared_back_link": + got[key] = dec["backLink"] == rec["backLink"] + elif key == "winner": + got[key] = superseding_nonce([_load(case, "decision_a.json"), + _load(case, "decision_b.json")]) elif key == "receipt_present": got[key] = rec is not None elif key == "both_signatures_ok": diff --git a/tests/vectors/decision_pairing_v0/normative/decision_only_escalate/decision.json b/tests/vectors/decision_pairing_v0/normative/decision_only_escalate/decision.json index 835fcfc..f804da6 100644 --- a/tests/vectors/decision_pairing_v0/normative/decision_only_escalate/decision.json +++ b/tests/vectors/decision_pairing_v0/normative/decision_only_escalate/decision.json @@ -20,6 +20,6 @@ "secretVersion": "v1", "sub": "agent:reader" }, - "signature": "017040a40f64f8ac718090ef545bb8edee738a110f18c9edbc71d345ed9fd08c0a538fde3dde59c9780bc1f4ef5ffc6e8b5ff79c5f56b1366b94f1f0cd353c7b", + "signature": "b3dd0dcf38a8d053bba6c11bc7a7f55f8af07b645a31b15ffe2cdba1c8c13d9aad7cf18bfe722c37adcbc06975bc8caa867eff2798b76230ae4e65ab82eeb796", "version": 1 } diff --git a/tests/vectors/decision_pairing_v0/normative/fallback_envelope_binding/receipt.json b/tests/vectors/decision_pairing_v0/normative/fallback_envelope_binding/receipt.json index 8be3907..894ebd1 100644 --- a/tests/vectors/decision_pairing_v0/normative/fallback_envelope_binding/receipt.json +++ b/tests/vectors/decision_pairing_v0/normative/fallback_envelope_binding/receipt.json @@ -6,6 +6,7 @@ }, "outcomeDerived": { "completedAt": "2026-06-01T10:00:00Z", + "decisionDigest": "sha256:220e1c323e7fbed50ab03c5d9db03b102998670fdce4cf1724b148370b05c046", "resultCommitment": { "projection": "{\"digest\":\"sha256:8b7262647fbf76fb7ae30d664e65069eaffc35aa793718beaee239309c9055cf\"}", "projectionDigest": "sha256:ca6005c73a4e80aacec2d23cfa734c18d5c8c303cf2f54e63d7693df474b422b" @@ -20,6 +21,6 @@ "secretVersion": "v1", "sub": "agent:reader" }, - "signature": "2ac6272a6c9e58fb9de42239c90e9700bd9252812aa29af989ce312535a6f34c", + "signature": "f3fe164d3f48d0c4fd62eae621af965c69bff243dd2eebcb322c2393143037cd", "version": 1 } diff --git a/tests/vectors/decision_pairing_v0/normative/fallback_envelope_binding/receipt_replayed.json b/tests/vectors/decision_pairing_v0/normative/fallback_envelope_binding/receipt_replayed.json index 24dd25e..8164d6c 100644 --- a/tests/vectors/decision_pairing_v0/normative/fallback_envelope_binding/receipt_replayed.json +++ b/tests/vectors/decision_pairing_v0/normative/fallback_envelope_binding/receipt_replayed.json @@ -6,6 +6,7 @@ }, "outcomeDerived": { "completedAt": "2026-06-01T10:00:00Z", + "decisionDigest": "sha256:220e1c323e7fbed50ab03c5d9db03b102998670fdce4cf1724b148370b05c046", "resultCommitment": { "projection": "{\"digest\":\"sha256:8b7262647fbf76fb7ae30d664e65069eaffc35aa793718beaee239309c9055cf\"}", "projectionDigest": "sha256:ca6005c73a4e80aacec2d23cfa734c18d5c8c303cf2f54e63d7693df474b422b" @@ -20,6 +21,6 @@ "secretVersion": "v1", "sub": "agent:reader" }, - "signature": "2ac6272a6c9e58fb9de42239c90e9700bd9252812aa29af989ce312535a6f34c", + "signature": "f3fe164d3f48d0c4fd62eae621af965c69bff243dd2eebcb322c2393143037cd", "version": 1 } diff --git a/tests/vectors/decision_pairing_v0/normative/substituted_attestation_backlink/receipt.json b/tests/vectors/decision_pairing_v0/normative/substituted_attestation_backlink/receipt.json index 2423d84..051f233 100644 --- a/tests/vectors/decision_pairing_v0/normative/substituted_attestation_backlink/receipt.json +++ b/tests/vectors/decision_pairing_v0/normative/substituted_attestation_backlink/receipt.json @@ -6,6 +6,7 @@ }, "outcomeDerived": { "completedAt": "2026-06-01T10:00:00Z", + "decisionDigest": "sha256:5d43e7841ff2a1acb412b7d82a32d3cfcf3f88ba440cb07f40558471d331ee22", "resultCommitment": { "projection": "{\"digest\":\"sha256:8b7262647fbf76fb7ae30d664e65069eaffc35aa793718beaee239309c9055cf\"}", "projectionDigest": "sha256:ca6005c73a4e80aacec2d23cfa734c18d5c8c303cf2f54e63d7693df474b422b" @@ -20,6 +21,6 @@ "secretVersion": "v1", "sub": "agent:reader" }, - "signature": "3d96ecdfac6c05ab91efb766fda66e1c515153621b1d880db1047744de24de96", + "signature": "955f0316a5d39a33e2f8fef26430ee3dccf0ea31cb7b7ff375da270f45429db5", "version": 1 } diff --git a/tests/vectors/decision_pairing_v0/normative/substituted_decision_under_shared_attestation/attestation.json b/tests/vectors/decision_pairing_v0/normative/substituted_decision_under_shared_attestation/attestation.json new file mode 100644 index 0000000..f2c852b --- /dev/null +++ b/tests/vectors/decision_pairing_v0/normative/substituted_decision_under_shared_attestation/attestation.json @@ -0,0 +1,29 @@ +{ + "alg": "HS256", + "issuerAsserted": { + "alg": "HS256", + "expSeconds": 300, + "iat": "2026-06-01T10:00:00Z", + "iss": "issuer://test", + "nonce": "fixed-attestation-nonce-000", + "secretVersion": "v1", + "sub": "agent:reader" + }, + "payloadDerived": { + "toolCalls": [ + { + "args": { + "projection": "{\"digest\":\"sha256:f735ff54b51fbc531287ba646ac3f66218ed91b783947e3e45d5e9ee3771b673\"}", + "projectionDigest": "sha256:5e97a35c5ee74c9e30fceea86cb31d5f3829f09022b37a728f243bd3f7b3ca1a" + }, + "name": "query_table", + "serverFingerprint": "sha256:1111111111111111111111111111111111111111111111111111111111111111" + } + ] + }, + "plannerDeclared": { + "intent": "show 10 employees" + }, + "signature": "7f7a6ae787bc5164de7e76185ecd81ee189d1f43fe383400c2bb92b12589f69b", + "version": 1 +} diff --git a/tests/vectors/decision_pairing_v0/normative/substituted_decision_under_shared_attestation/decision.json b/tests/vectors/decision_pairing_v0/normative/substituted_decision_under_shared_attestation/decision.json new file mode 100644 index 0000000..c4624f6 --- /dev/null +++ b/tests/vectors/decision_pairing_v0/normative/substituted_decision_under_shared_attestation/decision.json @@ -0,0 +1,25 @@ +{ + "alg": "HS256", + "backLink": { + "attestationDigest": "sha256:9429a30b849cdd7a8ed41680a667ef2e6516aa533320a9450eef87d31fdd6a44", + "attestationNonce": "fixed-attestation-nonce-000" + }, + "decisionDerived": { + "decidedAt": "2026-06-01T10:00:00Z", + "decision": "block", + "policyId": "policy:read-only/3", + "riskScore": "0.21", + "thresholdAllow": "0.30", + "thresholdBlock": "0.80" + }, + "issuerAsserted": { + "alg": "HS256", + "iat": "2026-06-01T10:00:00Z", + "iss": "issuer://test", + "nonce": "d7b", + "secretVersion": "v1", + "sub": "agent:reader" + }, + "signature": "abec50a42f2bfc150a132691c24bf84b6a7d2290ce2f7741b56fc90606bc1300", + "version": 1 +} diff --git a/tests/vectors/decision_pairing_v0/normative/substituted_decision_under_shared_attestation/expected.json b/tests/vectors/decision_pairing_v0/normative/substituted_decision_under_shared_attestation/expected.json new file mode 100644 index 0000000..af74d9f --- /dev/null +++ b/tests/vectors/decision_pairing_v0/normative/substituted_decision_under_shared_attestation/expected.json @@ -0,0 +1,7 @@ +{ + "decision_signature_ok": true, + "note": "Check A (shared attestation back-link) passes; Check B fails: the receipt's decisionDigest commits to a different decision than the one presented", + "receipt_signature_ok": true, + "records_paired": false, + "shared_back_link": true +} diff --git a/tests/vectors/decision_pairing_v0/normative/substituted_decision_under_shared_attestation/receipt.json b/tests/vectors/decision_pairing_v0/normative/substituted_decision_under_shared_attestation/receipt.json new file mode 100644 index 0000000..f5cdcf8 --- /dev/null +++ b/tests/vectors/decision_pairing_v0/normative/substituted_decision_under_shared_attestation/receipt.json @@ -0,0 +1,26 @@ +{ + "alg": "HS256", + "backLink": { + "attestationDigest": "sha256:9429a30b849cdd7a8ed41680a667ef2e6516aa533320a9450eef87d31fdd6a44", + "attestationNonce": "fixed-attestation-nonce-000" + }, + "outcomeDerived": { + "completedAt": "2026-06-01T10:00:00Z", + "decisionDigest": "sha256:1de259bb17e616f82ddc65624394f8a4f565e769cccb99720cdba912ee74f690", + "resultCommitment": { + "projection": "{\"digest\":\"sha256:8b7262647fbf76fb7ae30d664e65069eaffc35aa793718beaee239309c9055cf\"}", + "projectionDigest": "sha256:ca6005c73a4e80aacec2d23cfa734c18d5c8c303cf2f54e63d7693df474b422b" + }, + "status": "executed" + }, + "receiptAsserted": { + "alg": "HS256", + "iat": "2026-06-01T10:00:00Z", + "iss": "issuer://test", + "nonce": "r7", + "secretVersion": "v1", + "sub": "agent:reader" + }, + "signature": "ee80cc41a0078771560036aa32ecfaf08d9818e5426379aba72140ab595dae55", + "version": 1 +} diff --git a/tests/vectors/decision_pairing_v0/normative/substituted_pairing_nonce/receipt.json b/tests/vectors/decision_pairing_v0/normative/substituted_pairing_nonce/receipt.json index 8556a22..d1efbf0 100644 --- a/tests/vectors/decision_pairing_v0/normative/substituted_pairing_nonce/receipt.json +++ b/tests/vectors/decision_pairing_v0/normative/substituted_pairing_nonce/receipt.json @@ -6,6 +6,7 @@ }, "outcomeDerived": { "completedAt": "2026-06-01T10:00:00Z", + "decisionDigest": "sha256:fd7e280be8e94c4b4a778049060c81532844fd0479ba479bb9b4a5e70591f831", "resultCommitment": { "projection": "{\"digest\":\"sha256:8b7262647fbf76fb7ae30d664e65069eaffc35aa793718beaee239309c9055cf\"}", "projectionDigest": "sha256:ca6005c73a4e80aacec2d23cfa734c18d5c8c303cf2f54e63d7693df474b422b" @@ -20,6 +21,6 @@ "secretVersion": "v1", "sub": "agent:reader" }, - "signature": "e4705c4982b71f3040fc9ae43f8e74d4b0ede45876340ebe1e9c5603248ac646", + "signature": "c5f9c1e21d9333db3409fec2b62c5c872f97a35255a7ba12dfba3845164321b6", "version": 1 } diff --git a/tests/vectors/decision_pairing_v0/normative/supersession_equal_decidedat_tie/expected.json b/tests/vectors/decision_pairing_v0/normative/supersession_equal_decidedat_tie/expected.json index e219e69..7859ff5 100644 --- a/tests/vectors/decision_pairing_v0/normative/supersession_equal_decidedat_tie/expected.json +++ b/tests/vectors/decision_pairing_v0/normative/supersession_equal_decidedat_tie/expected.json @@ -1,6 +1,6 @@ { "both_back_links_ok": true, "both_signatures_ok": true, - "open_contract": "equal decidedAt needs a deterministic tie-break (e.g. lexicographic on record nonce); unspecified in the reference impl as of v0.50.0", - "winner": null + "note": "equal decidedAt resolves by lexicographic issuerAsserted.nonce, lowest wins (d5a < d5b)", + "winner": "d5a" } diff --git a/tests/vectors/decision_pairing_v0/normative/valid_pair_allow_executed/receipt.json b/tests/vectors/decision_pairing_v0/normative/valid_pair_allow_executed/receipt.json index 275ef75..86be3b2 100644 --- a/tests/vectors/decision_pairing_v0/normative/valid_pair_allow_executed/receipt.json +++ b/tests/vectors/decision_pairing_v0/normative/valid_pair_allow_executed/receipt.json @@ -6,6 +6,7 @@ }, "outcomeDerived": { "completedAt": "2026-06-01T10:00:00Z", + "decisionDigest": "sha256:bc1a28955291e73f991374b6ba5a72be528db6049ed2f53042d6d429fbe9ac9b", "resultCommitment": { "projection": "{\"digest\":\"sha256:8b7262647fbf76fb7ae30d664e65069eaffc35aa793718beaee239309c9055cf\"}", "projectionDigest": "sha256:ca6005c73a4e80aacec2d23cfa734c18d5c8c303cf2f54e63d7693df474b422b" @@ -20,6 +21,6 @@ "secretVersion": "v1", "sub": "agent:reader" }, - "signature": "03731115968380aa9fbf41dc24d89676ab19ca806ce914fbb95447ff2eb58760", + "signature": "f4b87ef796407eef8d6644ca001a616deb09838b4d2584a809c4fbb4ee3c9db6", "version": 1 }